When the clock strikes nine
You are building a bot that needs to post a status update exactly at 9:00 AM every morning. Or a sensor that dumps data to disk once an hour. You write the logic, run the code, and it works. Then you realize you need it to happen later, or repeatedly, or at a specific wall-clock time.
Rust does not have a setTimeout in the standard library. The standard library gives you primitives to measure time, but it does not give you a scheduler. You have to reach for the ecosystem. The most common path is tokio, which provides async sleep functions that integrate with the runtime. For complex schedules, you add a cron-style crate on top.
The two clocks in Rust
Rust separates time into two distinct types because they solve different problems. Confusing them is the source of most scheduling bugs.
SystemTime is wall-clock time. It represents the calendar date and time of day. It is what you see on your phone. It can jump. If the OS syncs with an NTP server and corrects the clock by two seconds, SystemTime jumps. If a user manually changes the time, SystemTime jumps.
Instant is monotonic time. It represents a stopwatch that only moves forward. It measures elapsed time. It is immune to wall-clock adjustments. The OS scheduler works in monotonic time. It does not know about 9:00 AM. It only knows "wake this task up in 3,600 seconds."
When you schedule a task for a specific wall-clock time, you must bridge the gap. You calculate the duration between now and the target wall-clock time, then ask the scheduler to sleep for that duration using monotonic time.
Scheduling a one-off task
The simplest case is waking up once at a specific moment. You calculate the target SystemTime, compute the duration until that moment, and sleep.
use std::time::SystemTime;
use tokio::time::sleep;
#[tokio::main]
async fn main() {
// Define the target wall-clock time.
// In real code, you'd parse "2024-01-01 09:00:00" using a crate like `chrono`.
let target = SystemTime::now() + std::time::Duration::from_secs(5);
// Calculate how long to wait.
// duration_since returns a Result because the target could be in the past.
let duration = target.duration_since(SystemTime::now()).unwrap();
// Sleep for the calculated duration.
// tokio::time::sleep takes a Duration and yields the task until it expires.
sleep(duration).await;
println!("Task executed at the target time.");
}
The duration_since call is the bridge. It measures the gap between the calendar and the stopwatch. The unwrap assumes the target is in the future. If the target is in the past, duration_since returns an error. Production code should handle that case, usually by executing immediately or skipping the run.
Convention aside: When you call tokio::time::sleep, the community expects you to .await it immediately or pass the future to a runtime method. Storing the Future returned by sleep and polling it manually defeats the purpose of the abstraction. Just await it.
The drift trap
The naive approach to recurring tasks is a loop with sleep. This introduces drift.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
loop {
// Sleep for one hour.
sleep(Duration::from_secs(3600)).await;
// Do work.
// If this takes 100ms, the next sleep starts 100ms late.
do_work().await;
}
}
If do_work takes 100 milliseconds, the loop wakes up 100 milliseconds late. The next sleep starts late. Over a day, the task drifts by 2400 seconds. Over a month, it drifts by hours. The task that should run at 9:00 AM eventually runs at 9:40 AM.
The fix is sleep_until. This function takes an absolute deadline. You recalculate the deadline every iteration. The sleep duration adjusts automatically to compensate for work time.
use std::time::SystemTime;
use tokio::time::{sleep_until, Instant};
#[tokio::main]
async fn main() {
// Start one hour from now.
let mut next_deadline = Instant::now() + std::time::Duration::from_secs(3600);
loop {
// Sleep until the absolute deadline.
// If do_work took time, this sleep will be shorter to catch up.
sleep_until(next_deadline).await;
// Do work.
do_work().await;
// Advance the deadline by exactly one hour.
// The loop corrects for any drift automatically.
next_deadline += std::time::Duration::from_secs(3600);
}
}
sleep_until is the standard pattern for recurring tasks. It anchors the schedule to the wall-clock (via the initial calculation) or to a fixed interval, and absorbs jitter. Drift is the enemy of precision. Use sleep_until to keep your schedule tight.
Scheduling at wall-clock times
If you need "every Monday at 9:00 AM", calculating deadlines manually becomes painful. You need to parse dates, handle timezones, and compute the next occurrence. This is where cron crates shine.
tokio-cron-scheduler or cron crates provide a stream of deadlines. You iterate the stream and execute tasks.
use tokio_cron_scheduler::{Job, JobScheduler};
#[tokio::main]
async fn main() {
// Create a scheduler.
let mut scheduler = JobScheduler::new().await.unwrap();
// Add a job that runs every day at 9:00 AM.
// The cron expression "0 9 * * *" means minute 0, hour 9, every day.
scheduler
.add(Job::new("0 9 * * *", |uuid, _l| {
// This closure runs on the scheduler's thread.
// For async work, spawn a task or use the async job API.
println!("Running daily task at 9 AM. Job ID: {}", uuid);
}))
.await
.unwrap();
// Start the scheduler.
scheduler.start().await.unwrap();
// Keep the main task alive.
loop {
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
}
}
The cron crate handles the calendar math. It computes the next deadline and triggers the job. You focus on the work.
Convention aside: When using cron crates, keep the job closures small. Do blocking work inside a cron job can starve the scheduler. Spawn a tokio::task::spawn_blocking if the job involves heavy CPU or blocking I/O. The scheduler expects quick callbacks.
Pitfalls and compiler errors
Scheduling code hits a few common walls.
Mismatched types. The compiler rejects mixing SystemTime and Instant with E0308 (mismatched types). sleep_until expects an Instant. If you pass a SystemTime, the code fails to compile. You must convert. The conversion is not a simple cast. You have to compute the offset.
use std::time::SystemTime;
use tokio::time::sleep_until;
#[tokio::main]
async fn main() {
let target = SystemTime::now() + std::time::Duration::from_secs(5);
// This fails to compile.
// sleep_until(target).await;
// Error[E0308]: mismatched types
// expected struct `tokio::time::Instant`, found struct `std::time::SystemTime`
}
The fix is to calculate the Instant deadline.
use std::time::SystemTime;
use tokio::time::{sleep_until, Instant};
#[tokio::main]
async fn main() {
let target = SystemTime::now() + std::time::Duration::from_secs(5);
// Convert SystemTime to Instant by measuring the duration from now.
let duration = target.duration_since(SystemTime::now()).unwrap();
let instant_deadline = Instant::now() + duration;
sleep_until(instant_deadline).await;
}
NTP jumps. If the system clock jumps backward while you are sleeping, SystemTime can become inconsistent. tokio handles this by converting SystemTime to Instant at the moment you call the sleep function. Once converted, the sleep is monotonic and immune to jumps. However, if you calculate a target SystemTime and then wait to convert it, a jump can make your duration calculation wrong. Always convert to Instant as close to the sleep call as possible.
Blocking the runtime. If you use std::thread::sleep inside an async task, you block the entire thread. tokio uses a thread pool. Blocking one thread can stall other tasks. Always use tokio::time::sleep in async code. The runtime yields the task and schedules other work while waiting.
Trust the runtime. If you block the thread, you break the concurrency model.
Choosing your scheduler
Pick the tool that matches your precision needs and complexity.
Use tokio::time::sleep when you need a relative delay and do not care about absolute timing. Use tokio::time::sleep for timeouts, backoff delays, and simple pauses where drift is acceptable.
Use tokio::time::sleep_until when you need to wake up at a specific monotonic deadline. Use tokio::time::sleep_until for recurring tasks where you must avoid drift. Use tokio::time::sleep_until when you calculate a target time and need the sleep to adjust for work duration.
Use a cron crate like tokio-cron-scheduler when you need complex calendar expressions. Use a cron crate for "every Monday at 9 AM", "first day of the month", or timezone-aware schedules. Use a cron crate when you want to offload date math to a tested library.
Use std::thread::sleep when you are in a synchronous context and do not need async. Use std::thread::sleep in main functions without #[tokio::main], or in background threads spawned with std::thread::spawn.
Use async-std::task::sleep when you are using the async-std runtime instead of tokio. The API is similar, but the types are different. Do not mix runtimes.