For AI agents: Documentation index at /llms.txt

Skip to content

Timers

Canisters can schedule code to run automatically after a delay or on a repeating interval. No external cron job required. This guide covers the timer APIs for Rust and Motoko, how system time works, upgrade handling, and when to use heartbeats instead.

System time

The IC exposes system time as nanoseconds since 1970-01-01 (Unix timestamp). The value is monotonically increasing, even across canister upgrades.

import Time "mo:core/Time";
let now_ns : Int = Time.now();

System time is constant within a single message execution: it does not advance mid-call. Different messages in the same round may observe different timestamps.

One-shot timers

Schedule a function to run once after a delay.

import Timer "mo:core/Timer";
func sendReminder() : async () {
// ...
};
let timerId : Timer.TimerId = Timer.setTimer<system>(#seconds 60, sendReminder);

Recurring timers

Schedule a function to run repeatedly at a fixed interval.

import Timer "mo:core/Timer";
func cleanup() : async () {
// periodic cleanup logic
};
let timerId : Timer.TimerId = Timer.recurringTimer<system>(#seconds 3600, cleanup);

A duration of 0 in Motoko will only expire once, not repeatedly (see Timer.mo).

For recurring tasks that mutate state, use set_timer_interval_serial in Rust to prevent concurrent invocations: if the interval fires while the previous invocation is still running, the new one is skipped:

ic_cdk_timers::set_timer_interval_serial(
Duration::from_secs(3600),
async || {
// safe to mutate state: only one invocation runs at a time
},
);

Canceling a timer

Both one-shot and recurring timers can be canceled before they fire. Canceling an already-expired or unrecognized ID is a no-op.

Timer.cancelTimer(timerId);

Common patterns

  • Periodic cleanup: purge expired cache entries, remove stale sessions, or compact data structures on a fixed schedule.
  • Scheduled data aggregation: periodically fetch exchange rates, collect metrics, or roll up statistics from child canisters.
  • Timed state transitions: expire auctions, unlock funds after a vesting period, or transition a proposal from “voting” to “decided” after a deadline.
  • Heartbeat-to-timer migration: replace a canister_heartbeat export with a recurring timer at the desired interval (see Heartbeats below).

Starting timers on canister init

A common pattern is to start a recurring timer when the canister is first installed:

Rust:

#[ic_cdk_macros::init]
fn init() {
ic_cdk_timers::set_timer_interval(
std::time::Duration::from_secs(3600),
|| async { ic_cdk::println!("Hourly task") },
);
}

See Canister lifecycle for init and upgrade hook details.

Timers after upgrades

Timers do not survive canister upgrades. When a canister is upgraded, its Wasm state is replaced and all pending timers are cleared.

To resume timers after an upgrade, re-register them in post_upgrade:

Motoko’s Timer module handles the scheduling mechanism. If you need state from before the upgrade to configure timers (such as a stored interval), read it from stable variables in postupgrade:

import Timer "mo:core/Timer";
persistent actor {
var intervalSecs : Nat = 3600;
system func postupgrade() {
ignore Timer.recurringTimer<system>(#seconds intervalSecs, periodicTask);
};
};

Pre- and post-upgrade hooks are error-prone. Avoid them when possible. If your timer interval is fixed, simply re-register it unconditionally in postupgrade rather than saving timer IDs to stable memory.

Cycle cost implications

Each timer execution is implemented as a self-canister call. Normal inter-canister call costs apply to each invocation. The periodic_tasks example benchmarks timers vs heartbeats and shows timers are more cost-effective than heartbeats for infrequent tasks.

Timer tasks are added to the canister’s input queue. If the canister or subnet is under load, actual execution may be delayed beyond the requested interval, and timeouts may result in duplicate execution. The timer interval is a minimum, not a guarantee. Make interval timer callbacks idempotent with respect to canister state to handle this safely.

The canister output queue is limited to 500 messages. This caps how many timers can fire in a single round. The CDK also enforces internal rate limits (250 concurrent timer calls globally, 5 per interval timer).

See Cycles and costs for current pricing.

Heartbeats (legacy)

Heartbeats call canister_heartbeat at intervals close to the blockchain finalization rate (~1s). They predate timers and have significant drawbacks:

  • Fixed interval close to block rate: cannot be adjusted
  • Run every block regardless of whether work is needed: burns cycles continuously
  • Cannot be disabled without upgrading to remove the export

Prefer timers for all new code. Heartbeats are only appropriate when you need sub-second execution or must respond to every block unconditionally.

To migrate from heartbeats to timers:

  1. Remove the canister_heartbeat export (or system func heartbeat in Motoko)
  2. Register a recurring timer with your desired interval in init and postupgrade
  3. Move the heartbeat logic into the timer callback

How the timer mechanism works

The IC protocol supports one global timer per canister via the ic0.global_timer_set() system API and a canister_global_timer handler.

The CDK timers library (ic-cdk-timers for Rust, mo:core/Timer for Motoko) builds multiple and periodic timers on top of this single protocol timer:

  1. Keeps a global list of all scheduled tasks in the canister heap
  2. Calls ic0.global_timer_set() to schedule the next upcoming task
  3. In canister_global_timer, runs each expired task as a self-canister call to isolate tasks from each other and from the library code
  4. Reschedules recurring tasks at the end of their execution

For protocol internals, see Timers and the IC interface specification.

Frequently asked questions

Do timers support deterministic time slicing (DTS)? Yes. Each timer executes as a self-canister call, so normal update message instruction limits apply with DTS enabled.

What happens if a timer handler awaits an inter-canister call? Normal await point rules apply: any new execution can start at the await point (a new message, another timer, or a heartbeat). The current timer handler resumes after the new execution finishes or reaches its own await point.

What happens if a periodic timer takes longer than its interval? With set_timer_interval, multiple invocations can run concurrently. With set_timer_interval_serial, the new invocation is skipped if the previous one is still running. If there are no await points, the timer is rescheduled after execution completes.

Time conversion

System time is returned in nanoseconds. For DateTime conversions, use these packages:

Limitations

  • Timer resolution is similar to the block rate: choose durations well above ~1s.
  • The CDK timers library uses relative time only. To schedule at an absolute time, calculate the duration from now to the target time manually.
  • Using timers for security (e.g., access control) is strongly discouraged. Timers vanish on upgrades and reinstalls, and reentrancy can undermine access checks.

Full example

For a complete working example with cycle tracking and multiple timers:

Next steps