A shared scheduler for background tasks in applications running on the Internet Computer.
What is IC Timers?
The Internet Computer is a network that runs software. Programs on it are called canisters. Like other applications, a canister may need to do some work later or repeat work in the background.
IC Timers is a tool that developers can add to a canister to organize that work. You can think of it as a shared alarm clock and task list for the whole application. It can tell the application when to run a task, keep related timers in one place, and report what happened when they ran.
For example, an application might use IC Timers to:
- remove expired records;
- process a queue a few items at a time;
- run regular database maintenance;
- try important work again after an interrupted attempt; or
- show operators which background tasks are waiting, running, or stopped.
People using the application do not interact with IC Timers directly. They benefit from background work that is easier for the application's developers to organize, monitor, and recover.
IC Timers does not permanently store an application's tasks. The application keeps the lasting record of what needs to happen and rebuilds its timers after an upgrade. Its Watchdog mode can preserve another attempt when work fails, but it does not promise that a task will happen exactly once.
Technical overview
| Field | Value |
|---|---|
| Released API line | 0.22 |
The 0.22 line uses Metrics 0.9 instruction summaries with u128 totals and
checked integer/fractional means. First lifecycle reconciliation remains atomic;
see the earlier release notes for that contract.
IC Timers is written in Rust 2024 and supports Rust
1.88.0 and newer. It uses ic-cdk-timers 1.0.0 as its private, underlying
timer service.
One IC Timers runtime can manage up to 64 named timers and 128 timer handles. It supports one-time work, work that repeats after returning normally, and Watchdog work that prepares another attempt before it starts. It also reports timer status, outcomes, and bounded resource measurements.
Why wrap ic-cdk-timers?
ic-cdk-timers is the right low-level mechanism for scheduling a simple
callback. The operational problem changes when a framework, a database, and
application code all schedule work independently.
We thought this wrapper was worthwhile because these are canister-wide questions:
- Which logical timers exist, and who owns each one?
- Is a timer inactive, armed, running, overdue, cancelled, or stale?
- What happened on its last run, and what did that run cost?
- What should be reconstructed after an upgrade?
- Must recurrence wait for normal completion, or survive failed work?
If every consumer builds a registry, recurrence loop, metrics table, and
upgrade protocol, operators still lack one reliable inventory and the most
failure-sensitive logic is duplicated. ic-timers puts that coordination
above the CDK provider without creating another provider.
Choose how a task should run
IC Timers offers three modes. Once runs a task one time unless explicitly
rescheduled. AfterCompletion can repeat after a normal return when its result
requests recurrence. Watchdog prepares another attempt before starting important
work. The timeline below shows the difference.
Watchdog deliberately uses two messages. The small scheduler callback
validates its generation, arms the next cadence successor, queues immediate
work, and returns. Only the later work callback invokes consumer code.
Watchdog limit: Watchdog protects against failure in the task itself. It cannot promise recovery if its small scheduling step fails or exceeds the Internet Computer's work limit.
A Watchdog can drain successful bounded work without waiting a full cadence:
let decision = match outcome ;
Use WatchdogReconcileState::ScheduledImmediately when lifecycle
reconstruction discovers inactive actionable debt, or call
WatchdogRegistration::ensure_scheduled_immediately() when a retained claim
discovers new debt later. “Immediately” arms a zero-delay scheduler for a later
replicated message; it never calls consumer work synchronously. The scheduler
still pre-arms the cadence successor before every work attempt, and normal
ContinueImmediately replaces that exact successor rather than adding one.
The 64-registration and 128-owned-handle limits bound this runtime's inventory and live capabilities. They do not cap the provider's queue memory: the pinned provider keeps cancelled deadline records until those deadlines are processed. Frequent deadline replacement and immediate continuation can therefore grow memory and incur later cleanup work. See the safety limits.
Ordinary recurrence is cheaper and is the correct default when work must
finish normally before another invocation is allowed. Use Watchdog only
when committing the next wake-up before fallible synchronous work is the
required protocol.
Ordinary callback results follow their policy. register_once and
reconcile_once require futures returning OnceRunResult with a OnceDecision;
the after-completion entry points require AfterCompletionRunResult with an
AfterCompletionDecision. Both permit Stop, immediate continuation, a relative
retry and an absolute deadline. Only AfterCompletionDecision includes
RecurAfterCompletion, which uses the configured cadence. A Once declaration
can still explicitly reschedule itself. Invariant-failure results force Stop,
and authoritative nested commands keep their existing arbitration precedence.
Configured recurrence can follow success, no work or a returned retryable
failure. A trap or instruction exhaustion prevents ordinary completion from
scheduling it; recovery-critical work needs Watchdog.
When the provider or CDK drops a confirmed ordinary delivery without normal
completion, its guard retires the exact generation. A retained declaration becomes
inactive with InactiveReason::Abandoned, Failed condition and an Unacknowledged
observation. Its owner can explicitly rearm it. Transient declarations and pending
unregistrations are removed; other pending schedules are discarded. No completion,
work count or performance sample is fabricated. A future that remains alive and
pending still owns Running state; cancellation does not interrupt it.
This abandonment path is covered by native drop and real-await PocketIC fixtures. The 0.13.2 hosted release gates passed those subjects on both supported macOS hosts; qualification is bound to that source. It does not recover application effects committed before an await or automatically retry work. Capture destructors must remain bounded, nontrapping and safe in CDK cleanup.
Completion classification records what happened; the supplied decision selects
what to schedule. A retryable failure paired with Stop does not automatically
retry, and success paired with Stop does not recur. The result constructors
preserve the decision for success, no work and retryable failure, and force
Stop for invariant failure.
Result construction does not check relative delays. After authoritative commands have been applied, the runtime checks any selected delay's nanosecond encoding and successor deadline. An invalid selected delay stops the timer with a typed control failure, observable in a retained declaration's snapshot. A winning exact reconciliation can discard an invalid callback delay before that check.
Minimal Once example
The examples below use the released 0.22 API.
Select one exact published package version when this crate participates in a shared framework/application registry:
[]
= "=0.22.1"
Every framework and application crate linked into the same canister must use that same exact package version. Mixing this pin with an older exact release can resolve two package identities and therefore create two independent timer registries.
With typed registration setup, initialize the runtime from the canister's existing lifecycle owner, create and schedule a timer in one call, and retain its non-clone registration capability:
use ;
use ;
The returned registration is the sole-owner control capability. Keep it in
volatile owner state so that code can inspect, ensure, reconcile, cancel, or
unregister that exact claim. The capability types are #[must_use] and are
intentionally not cloneable.
For fixed declarations, use reconcile_once,
reconcile_after_completion, or reconcile_watchdog during both init and
post_upgrade. Those helpers always create retained declarations, including
inactive ones. Transient RemoveWhenStopped declarations use the direct
registration functions.
Lifecycle helpers fill an empty registration slot only after the requested initial state succeeds. If that first call returns an error, the fresh declaration and handles are cleaned up and its captures are released; the slot remains empty, allowing a retry to install its new callback. A failed later reconciliation retains the existing registration and its callback.
All three registration functions take one TimerStart before the callback.
Retained { initial: None } creates an inactive retained declaration;
Retained { initial: Some(schedule) } starts one scheduled.
RemoveWhenStopped { initial: schedule } requires a schedule and removes the
declaration when it stops. DeclarationLifetime remains an observation in
snapshots, rather than a separate constructor input.
A scheduled start returns authority only after its first wakeup is installed.
A returned error retires the failed creation and provider handles before
dropping callback captures. Durations and deadline arithmetic remain checked.
The first deadline is independent of recurrence cadence: set initial to
TimerSchedule::After(Duration::from_nanos(cadence.as_nanos())) inside the
chosen start configuration to begin at that cadence.
Typed starts preserve the atomic construction contract introduced in 0.18. The 0.21 line extends creation cleanup to first lifecycle reconciliation; existing later control retains its failure semantics. See the typed setup decision and qualification scope.
Runtime ownership
The registry is volatile. It stores no stable timer policy, provider handle, generation, snapshot, epoch, or application recovery authority. After an upgrade, consumers derive desired timers from their own durable state and reconstruct them synchronously before downstream post-upgrade work.
Callbacks run without a registry borrow. Nested ensure, reconcile, cancel, and unregister requests are arbitrated by one canonical pending command and the exact callback generation.
Since the 0.8 API, a sleeping Watchdog can reconcile its own exact
wake-up with reconcile_schedule(Some(TimerSchedule::At(deadline_ns))) and
return WatchdogDecision::ScheduleAt(next_deadline_ns) after successful work.
The scheduler still commits the cadence recovery successor before work runs.
Return Stop when idle and reconstruct with WatchdogReconcileState::ScheduledAt
when durable demand supplies a deadline. See the
0.8 contract.
Truthful observability
timer_snapshot and timer_inventory return inert values; snapshots never
become mutation authority. The inventory carries the runtime epoch even when
its ordered timer slice is empty. timer_inventory is the 0.6 replacement for
the removed bare-vector timer_snapshots function.
| Observation | Meaning |
|---|---|
| Identity and policy | Deterministic ownership and the configured execution contract |
| Runtime state | Policy-specific inactive, armed, running, successor, and watchdog-attempt state |
| Counters | Requested, armed, started, completed, outcomes, cancellations, stale work, coalescing, and unacknowledged work |
| Instructions | Completed scheduler/work sample count plus total, latest, and maximum measurements |
| Memory | Latest start/end Wasm and stable page extents plus maximum observed growth |
| Registration identity | Runtime epoch plus registration sequence; changes when counters are replaced, including within one epoch |
Before subtracting cumulative measurements, compare registration_id() within
the same canister, compare the same callback role and units, and require advancing
source times and append-only continuity. Cancellation preserves the identifier;
unregister/re-register changes it. Increasing numbers alone cannot detect a reset.
Callback generation() does not identify a counter lifetime.
Event counters and memory sample counts conservatively treat u64::MAX as
saturated. Instruction measurements use Metrics 0.9 ordered MeasurementSummary
values with u128 totals and integer means, and u64 samples and counts. Totals
above u64::MAX remain exact. An exactly reached cap remains exact, while actual
count/total overflow
is recorded by aggregate().samples_overflowed() and total_overflowed().
mean() returns Ok(None) for no completed samples, Ok(Some(0)) for a completed
zero measurement, and an error after actual overflow. Latest and maximum samples
remain available after overflow. Preserve the tracked summary when computing a
mean or an admitted interval; clipped count/total fields alone lose overflow
provenance. Numeric interval admission does not establish registration continuity.
The summary exposes checked scaled_mean(scale) for
fractional reports. It computes floor(total * scale / samples) from the tracked
total, preserving fractions lost by scaling an already rounded integer mean.
It distinguishes empty from measured zero and returns SampleCountOverflow on
actual count overflow even when scale is zero. An exact count bounds the scaled
result within u128; there is no separate projection-error case.
Use ic_timers::MeasurementSummary or the same Metrics package when exchanging
these values with other libraries: the prepared Timers 0.22 cut uses Metrics 0.9.
Older Metrics lines have distinct Rust types. Preserve
wide totals in reports/codecs, or use checked conversions when a narrower
consumer field is required. The adoption owner
records the selected graph and qualification limits.
Each instruction measurement covers the accepted ic-timers execution
interval: it begins immediately before callback acceptance and ends after
completion processing and any successor binding. It is not an exclusive
measurement of application code, nor is it the complete IC message. Provider
dispatch before entering the runtime, provider return/reply work, page reads,
and the bounded summary write remain outside the instruction delta. Do not use
the library aggregate alone as proof against the IC message instruction limit.
Trapped or instruction-exhausted work produces no fabricated sample. A
terminal RemoveWhenStopped callback can delete its declaration before the
final measurement is retained; no timer then remains to expose that sample.
Consumers that require a durable terminal audit receipt must store it outside
the volatile timer registry rather than treating a snapshot as authority.
Wasm and stable-memory values are 64 KiB page extents: they are runtime high-water observations, not exact live bytes. Sub-page allocator liveness remains an owner-derived measurement. Async ordinary samples may also include canister activity interleaved while the callback future awaits.
Read scheduler and work memory independently, and preserve Option when
projecting the values:
use MemoryPageSummary;
// Given a TimerSnapshot named `snapshot`:
let performance = snapshot.observability.performance;
let scheduler = completed_memory;
let work = completed_memory;
Here None means no callback of that role completed normally. Some with
equal start/end extents and maximum growth of zero is a real completed sample
that observed no page growth.
has_armed_wakeup() is a claim-scoped observation of canonical provider-handle
ownership. It is not a delivery guarantee or durable demand. When durable
demand requires a timer, call ensure_scheduled() or
ensure_scheduled_immediately() unconditionally instead of using the
observation as a check-then-arm guard.
Lifecycle and shared-registry rules
- The canister's existing lifecycle owner calls
initialize_runtime(). - Frameworks and applications reconcile their retained declarations from consumer-owned durable authority.
- Application hooks run only after required reconstruction.
- The same sequence runs during
initandpost_upgrade.
The crate exports no lifecycle hook, lifecycle macro, public Candid endpoint, or consumer-work executor.
🚨 Every consumer linked into one canister must resolve the same
ic-timersCargo package ID. Two resolved versions create two independent library statics and therefore two registries.
The registry's 128-handle ceiling does not reserve capacity in the provider's
canister-wide limit of 250 outstanding dispatches. A composed canister must
also inventory or migrate every remaining direct ic-cdk-timers user.
Canic, IcyDB, and application timers motivated this shared model. The Canic adapter contract and IcyDB adoption record preserve the exact downstream mapping and evidence without making patch-specific worktree state part of this README.
For a canister with one simple callback and no need for shared inventory,
metrics, lifecycle reconciliation, or a recovery policy, using
ic-cdk-timers directly remains the simpler choice.
Guarantees and limits
| Guarantee | Boundary |
|---|---|
| Deterministic bounded identity | Three non-empty components, each at most 64 UTF-8 bytes |
| Unique canonical ownership | One declaration per identity within one resolved runtime |
| Checked scheduling | Positive cadence and checked deadline arithmetic |
| Harmless stale delivery | Identity, claim, callback generation, and role are validated |
| Fail-closed binding | Failed provider effects cannot leave false scheduled state |
| Safe cancellation | Owned provider handles are cleared; already-running work is not interrupted |
| Watchdog recovery | Pre-armed successor survives consumer-work trap or exhaustion |
| No catch-up storm | Overdue watchdog cadence coalesces from current dispatch time |
Read SAFETY.md before relying on the watchdog protocol or operational measurements.
Development and evidence
The root Cargo.toml owns all dependencies, including Testkit, for the library
and three unpublished probes. Every member uses workspace = true and shares
the root Cargo.lock. Ordinary Cargo commands default to the library; the probe
targets select their packages explicitly and preserve their Wasm settings through
the timer-probe profile.
The reviewed maintenance catalog supplies named inspection procedures. It enables no schedule; local validation and release authority remain defined in AGENTS.md.
| Command | Purpose |
|---|---|
make update-dev |
Install the pinned toolchain, components, host/IC/Cargo tools and Testkit server, Wasm target, and formatting hook |
make install-host-tools / make host-tools-check |
Install pinned jq/yq/ripgrep/cloc or verify the complete bundle offline |
make install-ic-tools / make ic-tools-check |
Install the pinned five-tool IC bundle or verify it offline |
make install-tools / make tools-check |
Prepare or verify host, IC and Cargo tools, then the selected Testkit CLI/server |
make install-testkit-server / make pocketic-check |
Prepare the root-lock selected Testkit CLI/server or check admission offline |
make cloc |
Report Rust LOC/test counts for all root workspace members |
make fmt / make fmt-check |
Sort manifests and format or check Rust for all workspace members |
make ci |
Run the normal warning-denied checks, native tests, Wasm build, and package checks |
make msrv |
Check the library and its doctests with Rust 1.88.0 |
make testing-check |
Check workspace formatting and lint supported unpublished probes with Rust 1.88.0 |
make repository-check |
Validate repository-only documentation, evidence, or tooling work |
make pocketic-watchdog |
Run the focused real-canister watchdog recovery matrix |
make pocketic-cohorts |
Compare real-canister policy cohorts and measurements |
make release-impact |
Classify changes as crate-impacting, repository-only, or absent |
Normal development and hosted CI use Rust 1.99.0. Hosted CI also lints every
supported probe configuration with both Rust 1.99.0 and Rust 1.88.0.
Prepare the complete pinned toolsets through make update-dev, or prepare the
Rust toolchain and run make install-tools followed by make tools-check.
Common Cargo tools use .tools/rust/common/bin; update interactive shell PATH
exports when adopting the
new installation contract.
make actions-check verifies the bundle offline and delegates
Actions and Cargo declaration checks to the reviewed shared parser; it never
downloads tools. See the setup and pin boundaries.
Both fmt and fmt-check first require the exact cargo-sort pin and prepared
rustfmt for the selected toolchain; missing tools require explicit setup.
To run the development-toolchain probe checks locally, use
make testing-check MSRV=1.99.0.
The host-side real-canister suites select Testkit from the root lockfile.
make install-testkit-server explicitly installs that version's CLI and prepares
its authenticated server under ignored .tools/testkit-server; make pocketic-check
checks it offline. update-dev, hosted CI and release preparation run explicit
setup. Ordinary checks never download the server. The old Make binary override
and target/tools provisioning route are removed. Testkit owns server versions,
asset hashes and compatibility; Timers passes its admitted path to the probes.
Testkit starts a caller-owned server and fresh IC instance for each fixture, with
one 60-second deadline covering both startup phases. The instance is dropped before its
server; upstream instance deletion itself remains unbounded.
Recorded PocketIC 15 evidence covers real work traps, 40-billion-instruction exhaustion, insufficient cycles followed by top-up, upgrade reconstruction, stop/resume, overdue coalescing, cancellation gaps, duplicate demand, simultaneous timers, trap isolation, and rejection of external executor ingress. The testkit/PocketIC 16 harness requires renewed execution of the full recovery and cohort gates; source review and verified artifact hashes do not qualify those behaviors.
Documentation map
| Document | Purpose |
|---|---|
| Architecture | Canonical ownership, module boundaries, and lifecycle model |
| Safety boundary | Exact guarantees, assumptions, and non-guarantees |
| Observability design | Snapshot and measurement semantics |
| 0.5 design | Policy-specific callback capabilities and migration boundary |
| 0.6 release note | Atomic inventory epoch and hard-cut migration |
| 0.7 release note | Immediate progress-sensitive Watchdog continuation |
| 0.8 release note | Registration continuity and exact Watchdog deadlines |
| Runtime evidence | PocketIC protocol, isolation, size, and instruction evidence |
| Current status | Compact maintainer handoff |
| Release guide | Versioning, validation, and publication workflow |
License
MIT