The retry loop that broke production
You are writing a retry loop for a flaky API. The logic seems simple: wait one second, try again. If it fails, wait two seconds. Then four. Then eight. You write delay = delay * 2. You run the code. The delays grow. Eventually, the delay overflows and wraps to zero. Your loop hammers the API at full speed, burning requests and triggering rate limits. Or worse, you mixed up milliseconds and seconds in your head, and your "quick" retry waits for an hour.
std::time::Duration stops these mistakes before they happen. It turns time into a type. The compiler forces you to handle units explicitly. It prevents you from adding a number to a time span. It catches overflow before it wraps. You get nanosecond precision without floating-point drift, and you get type safety without the boilerplate.
Duration is a span, not a point
Duration represents a non-negative span of time. It measures a gap between two moments, not a moment itself. Think of it as a ruler for time. You use it to say "wait this long" or "the operation took this much time." You do not use it to say "the meeting starts at 3 PM."
The type is always positive. Zero is allowed. Negative durations are impossible. If you need to represent a point in time, you reach for Instant or SystemTime. If you need a negative interval, you store the sign separately or use a signed integer type with a clear unit.
Duration stores time as two integers: seconds and nanoseconds. The nanoseconds part is always less than one billion. This design gives you nanosecond precision without floating-point arithmetic. No drift. No rounding errors. Just exact integer math wrapped in a type that enforces the rules.
Minimal example: creating and adding durations
You create durations using helper methods. The helpers make the unit explicit. You add durations with standard arithmetic operators. The result is a new duration. Durations are immutable.
use std::time::Duration;
fn main() {
// from_secs creates a duration from whole seconds.
// The nanoseconds part is zero.
let base_delay = Duration::from_secs(2);
// from_millis creates a duration from milliseconds.
// The compiler converts this to seconds and nanoseconds internally.
let jitter = Duration::from_millis(500);
// Addition returns a new Duration.
// The original values stay unchanged.
let total_delay = base_delay + jitter;
// Debug formatting shows the human-readable span.
// Output: 2.5s
println!("Total delay: {:?}", total_delay);
// Check if a duration is zero.
// Useful for skipping sleeps in fast-path logic.
let zero = Duration::from_secs(0);
assert!(zero.is_zero());
}
The community prefers helper methods like from_secs and from_millis over the raw new constructor. The raw constructor takes seconds and nanoseconds as separate arguments. It requires you to ensure the nanoseconds are less than a billion. The helpers handle the conversion and make the intent clear. Duration::from_secs(2) reads like English. Duration::new(2, 0) requires mental parsing.
How Duration works under the hood
Duration wraps two fields: secs: u64 and nanos: u32. The nanos field is capped at 999,999,999. When you add durations, the implementation handles the carry. If the nanoseconds overflow, they roll over into seconds. This keeps the internal representation normalized.
The maximum duration is roughly 584 years. That is u64::MAX seconds. If you try to create a duration larger than that, you hit overflow. The standard arithmetic operators panic on overflow in debug mode. In release mode, they wrap. This is a trap. A wrapped duration can become zero or a tiny value, causing loops to misbehave.
You avoid this by using checked or saturating arithmetic. checked_add returns None on overflow. saturating_add caps the result at Duration::MAX. The choice depends on your error handling strategy. If overflow means a bug, use checked_add and handle the None. If overflow means "wait as long as possible," use saturating_add.
Realistic example: exponential backoff
Exponential backoff is the classic use case for Duration. You double the delay after each failure, up to a maximum. You need multiplication, addition, and capping. You also need to avoid overflow. saturating_mul is the tool for this job. It multiplies and caps at the maximum value instead of panicking or wrapping.
use std::time::Duration;
use std::thread;
/// Calculates the next delay in an exponential backoff sequence.
/// Caps the delay at max_delay to prevent unbounded growth.
fn next_backoff(current_delay: Duration, max_delay: Duration) -> Duration {
// saturating_mul doubles the delay.
// If the result overflows, it returns Duration::MAX instead of panicking.
let doubled = current_delay.saturating_mul(2);
// min caps the delay at the maximum allowed value.
// This prevents the delay from growing beyond the policy limit.
doubled.min(max_delay)
}
fn main() {
let max_delay = Duration::from_secs(60);
let mut delay = Duration::from_millis(100);
// Simulate 10 retry attempts.
for attempt in 0..10 {
println!("Attempt {}: waiting {:?}", attempt + 1, delay);
// In real code, you would perform the operation here.
// thread::sleep(delay);
// Calculate the next delay for the following attempt.
delay = next_backoff(delay, max_delay);
}
}
The saturating_mul call is the safety net. Without it, a long-running process could eventually overflow the delay and wrap to zero. The loop would then hammer the resource at full speed. The min call enforces the policy cap. You get a robust backoff strategy that handles overflow gracefully and respects limits.
Use saturating arithmetic for backoff. Panics in production loops are unkind.
Pitfalls: overflow, units, and types
Duration arithmetic panics on overflow in debug mode. This is a feature. It catches bugs early. You see the panic during development, not in production. The error message points to the operation that caused the overflow. You fix the logic or switch to saturating arithmetic.
If you try to add a raw number to a duration, the compiler rejects you. You cannot write duration + 5. The compiler does not know if 5 means seconds, milliseconds, or nanoseconds. You must convert the number to a duration first.
The compiler rejects this with E0277 (trait bound not satisfied) because u64 does not implement Add<Duration>. You need Duration::from_secs(5) or Duration::from_millis(5). This forces you to be explicit about units. It prevents the "off-by-1000" bugs that plague code using raw integers for time.
Another pitfall is mixing Duration with Instant or SystemTime. Instant measures elapsed time since a point in the past. SystemTime measures wall-clock time. You can add a Duration to an Instant to get a future Instant. You can subtract two Instants to get a Duration. You cannot add two Instants. You cannot subtract a Duration from a Duration to get an Instant. The types enforce the semantics.
If you try to add two Instants, the compiler rejects you with E0277. The operation makes no sense. Two points in time do not sum to a meaningful value. The type system blocks the nonsense.
Trust the type system. If it compiles, the units are right. If it rejects you, you are trying to do something that has no meaning.
Async runtimes and Duration
Async runtimes like tokio and async-std accept Duration directly. You pass a Duration to sleep or timeout. The runtime uses the duration to schedule the wake-up. You do not need to convert to runtime-specific types.
use std::time::Duration;
use tokio::time::{sleep, timeout};
#[tokio::main]
async fn main() {
// sleep takes a Duration and yields control until the time elapses.
let delay = Duration::from_millis(500);
sleep(delay).await;
// timeout takes a Duration and a future.
// It returns Ok(result) if the future completes in time.
// It returns Err(Elapsed) if the duration expires first.
let result = timeout(Duration::from_secs(2), async {
// Simulate a slow operation.
sleep(Duration::from_secs(1)).await;
"Done"
})
.await;
match result {
Ok(value) => println!("Completed: {}", value),
Err(_) => println!("Timed out"),
}
}
The community convention is to use std::time::Duration in public APIs. It keeps your crate independent of the runtime. You can use tokio or async-std or smol without changing your function signatures. The runtimes all accept std::time::Duration. You import std::time::Duration once and use it everywhere.
The tokio crate re-exports Duration as tokio::time::Duration. It is the same type as std::time::Duration. You can use either import. The convention favors std::time::Duration to avoid implying a runtime dependency. If your code uses tokio::time::Duration, readers might assume you need tokio to run it. You do not. The type is standard.
Convention aside: stick to std::time::Duration in signatures. It signals that your code works with any runtime.
Decision: Duration vs Instant vs SystemTime
You have three time types in the standard library. You choose based on what you are measuring.
Use Duration when you need a span of time, like a timeout, a delay, or a calculated interval. Use Instant when you need to measure elapsed time since a specific point in the past, like benchmarking code or tracking how long a request took. Use SystemTime when you need a wall-clock timestamp, like logging an event or scheduling a task for a specific date. Reach for chrono or time crates when you need calendar arithmetic, time zones, or parsing human-readable dates; the standard library stops at spans and points.
Duration is the ruler. Instant is the stopwatch. SystemTime is the wall clock. Keep them separate. The compiler will help you if you try to mix them up.