Time

#316 Aug 2026

316. checked_duration_since โ€” Your Deadline Passed, and Instant Subtraction Said Zero

This morning’s bite 315 said Instant has no Result to handle. True โ€” but its subtraction hides a different trap: past a deadline, deadline - now is silently 0, and your retry loop spins forever with a zero timeout.

Since Rust 1.60, subtracting Instants in the wrong order doesn’t panic โ€” it saturates to zero:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::time::{Duration, Instant};

let earlier = Instant::now();
let later = earlier + Duration::from_secs(5);

// right order: the real gap
assert_eq!(later.duration_since(earlier), Duration::from_secs(5));

// wrong order: silently zero โ€” no panic, no Err
assert_eq!(earlier.duration_since(later), Duration::ZERO);
assert_eq!(earlier - later, Duration::ZERO);

That’s kind to platform clock bugs, but cruel to deadline code. The classic pattern computes the remaining budget each iteration:

1
2
3
4
5
let remaining = deadline - Instant::now();
socket.set_read_timeout(Some(remaining)); // ๐Ÿ’ฃ once the deadline
                                          // passes, remaining is 0
                                          // โ€” which many APIs read
                                          // as "no timeout at all"

Zero never looks like “we’re done” โ€” it looks like “no time elapsed”. Worse, set_read_timeout(Some(Duration::ZERO)) is an error on std sockets, and other APIs treat zero as infinite wait.

checked_duration_since tells the truth

None means the deadline is behind you โ€” an unmissable, type-checked “time’s up”:

1
2
3
4
5
6
7
use std::time::Instant;

fn remaining(deadline: Instant) -> Option<std::time::Duration> {
    deadline.checked_duration_since(Instant::now())
}

// Some(budget) โ†’ keep waiting; None โ†’ deadline passed, bail
1
2
3
4
5
let earlier = Instant::now();
let later = earlier + Duration::from_secs(5);

assert_eq!(later.checked_duration_since(earlier), Some(Duration::from_secs(5)));
assert_eq!(earlier.checked_duration_since(later), None);

And when clamping to zero is what you want, say it explicitly with saturating_duration_since โ€” same behavior as the bare subtraction, but the next reader knows it’s deliberate:

1
assert_eq!(earlier.saturating_duration_since(later), Duration::ZERO);

Same trio as integer arithmetic โ€” bare op, checked_*, saturating_* โ€” just on the clock. Pick the one that says what you mean.

#315 Aug 2026

315. SystemTime::duration_since โ€” NTP Set the Clock Back, and elapsed().unwrap() Panicked

You timed a job with SystemTime and it ran fine for weeks โ€” until NTP stepped the clock backwards and that innocent .elapsed().unwrap() took the service down.

SystemTime is the wall clock. It can jump backwards at any moment: NTP corrections, a manual clock change, a VM resuming from snapshot. That’s why every subtraction on it returns a Result โ€” the “later” time can end up earlier:

1
2
3
4
5
6
use std::time::SystemTime;

let start = SystemTime::now();
do_work();
let taken = start.elapsed().unwrap(); // ๐Ÿ’ฃ panics if
                                      // the clock stepped back

The Err isn’t noise to unwrap away โ€” it’s the whole point of the API. It even tells you how far backwards the clock went:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
use std::time::{Duration, SystemTime};

let earlier = SystemTime::now();
let later = earlier + Duration::from_secs(5);

// forward: Ok(gap)
assert_eq!(
    later.duration_since(earlier).unwrap(),
    Duration::from_secs(5)
);

// backwards: Err, carrying the gap
let err = earlier.duration_since(later).unwrap_err();
assert_eq!(err.duration(), Duration::from_secs(5));

Measuring time? Use Instant

If you’re timing how long something took, you don’t want the wall clock at all. Instant is the monotonic clock โ€” the OS guarantees it never goes backwards, so there’s no Result to handle:

1
2
3
4
5
use std::time::Instant;

let start = Instant::now();
do_work();
let taken = start.elapsed(); // Duration, always valid

The rule of thumb

Instant for durations, SystemTime for timestamps. The one thing Instant can’t do is tell you when โ€” it’s opaque, with no relation to any calendar. When you need an actual timestamp (log lines, cache expiry dates, file metadata), that’s SystemTime’s job:

1
2
3
4
5
6
use std::time::{SystemTime, UNIX_EPOCH};

let unix_secs = SystemTime::now()
    .duration_since(UNIX_EPOCH)
    .expect("clock before 1970")
    .as_secs();

And if a backwards step is survivable in your context, unwrap_or_default() turns it into a zero duration instead of a panic โ€” the same “clamp at zero” move as saturating_sub in bite 312.

#314 Aug 2026

314. Duration::mul_f64 โ€” timeout * 1.5 Doesn't Compile, So You Round-Tripped Through Floats

Duration * 2 compiles, Duration * 1.5 doesn’t โ€” and the usual workaround tears the duration apart into an f64 just to build it back up again.

Duration implements Mul<u32>, so doubling a timeout is easy. But backoff factors are rarely whole numbers โ€” the classic multiplier is 1.5 or 2.0-with-jitter, and that’s where the type checker stops you:

1
2
3
4
5
6
7
use std::time::Duration;

let delay = Duration::from_millis(200);

let doubled = delay * 2;        // fine: Mul<u32>
// let next = delay * 1.5;      // error: cannot multiply
//                              // `Duration` by `{float}`

The workaround everyone reaches for is the float round-trip:

1
let next = Duration::from_secs_f64(delay.as_secs_f64() * 1.5);

It works, but it’s noisy, and it funnels a perfectly good Duration through a lossy f64 in both directions.

Multiply the duration directly

mul_f64 (and div_f64) have been on Duration since Rust 1.38 โ€” they do the scaling in one call and keep nanosecond precision:

1
2
3
4
5
6
use std::time::Duration;

let delay = Duration::from_millis(200);

assert_eq!(delay.mul_f64(1.5), Duration::from_millis(300));
assert_eq!(delay.div_f64(2.0), Duration::from_millis(100));

Which makes an exponential backoff loop read exactly like the algorithm:

1
2
3
4
5
6
let mut delay = Duration::from_millis(100);
for _ in 0..4 {
    delay = delay.mul_f64(1.5).min(Duration::from_secs(2));
}
// 100ms โ†’ 150ms โ†’ 225ms โ†’ 337.5ms โ†’ 506.25ms
assert_eq!(delay, Duration::new(0, 506_250_000));

One caveat carried over from float land: mul_f64 panics if the factor produces a negative, non-finite, or overflowing result โ€” the same failure mode as from_secs_f64 in bite 311. If your factor comes from untrusted math (jitter that can dip negative), clamp it first or go through Duration::try_from_secs_f64.

That closes out the week’s Duration tour: 311 built one from a float safely, 312 subtracted without panicking, 313 divided two of them โ€” and today’s bite scales one without leaving the type.

#313 Aug 2026

313. div_duration_f64 โ€” Your Progress Bar Sat at 0% Because as_secs() Truncated First

Dividing two Durations with as_secs() is integer division โ€” 1.5s of 60s truncates to 0, and your progress bar never moves.

The obvious way to compute “how far along are we” divides seconds by seconds:

1
2
3
4
5
6
7
8
use std::time::Duration;

let elapsed = Duration::from_millis(1500);
let total = Duration::from_secs(60);

// 1 / 60 == 0 โ€” integer division truncates
let frac = elapsed.as_secs() / total.as_secs();
assert_eq!(frac, 0);

as_secs() returns u64, so the sub-second part is gone before the division even runs, and the quotient truncates to zero on top of that. Switching to as_millis() just moves the truncation point; switching both sides to as_secs_f64() works but says nothing about intent.

Divide the durations directly

div_duration_f64 (stable since 1.80) divides one Duration by another and gives back the ratio as a float, sub-second precision included:

1
2
3
4
5
6
7
use std::time::Duration;

let elapsed = Duration::from_millis(1500);
let total = Duration::from_secs(60);

let frac = elapsed.div_duration_f64(total);
assert_eq!(frac, 0.025);

It reads in the same direction as the math: elapsed.div_duration_f64(total) is elapsed รท total. The same shape works for any “how many times does this fit” question โ€” like a benchmark speedup:

1
2
3
4
let old = Duration::from_millis(750);
let new = Duration::from_millis(250);

assert_eq!(old.div_duration_f64(new), 3.0);

Two things worth knowing: there’s a div_duration_f32 twin if you’re feeding a graphics API, and dividing by Duration::ZERO follows float semantics โ€” you get inf, not a panic. That’s one less edge case than the integer route, where total.as_secs() being zero would have crashed the division outright.

Like bite 311 and bite 312, the theme is the same: Duration already has a method for the math you’re about to do by hand โ€” and the hand-rolled version is where the bugs live.

#312 Aug 2026

312. Duration::saturating_sub โ€” Your Timeout Budget Ran Out, and the Subtraction Panicked

Duration - Duration panics on underflow โ€” and budget - elapsed underflows the moment a slow operation blows the budget.

A classic timeout-budget loop: give the whole request 200 ms, subtract what each step used, hand the rest to the next step. The subtraction is the trap:

1
2
3
4
5
6
7
use std::time::Duration;

let budget = Duration::from_millis(200);
let elapsed = Duration::from_millis(250);

// panics: overflow when subtracting durations
let remaining = budget - elapsed;

Duration is unsigned โ€” there is no negative duration โ€” so the moment elapsed exceeds budget, Sub has nothing valid to return and panics. It works fine in tests where every step is fast, then crashes in production the first time a step stalls.

The fallible and saturating versions

checked_sub returns an Option, which makes “budget exhausted” an explicit case instead of a crash:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::time::Duration;

let budget = Duration::from_millis(200);
let slow = Duration::from_millis(250);
let fast = Duration::from_millis(50);

assert_eq!(budget.checked_sub(slow), None);
assert_eq!(
    budget.checked_sub(fast),
    Some(Duration::from_millis(150))
);

If “no time left” should just mean zero โ€” say, the value goes straight into recv_timeout from bite 283 โ€” saturating_sub clamps instead:

1
2
let remaining = budget.saturating_sub(slow);
assert_eq!(remaining, Duration::ZERO);

The same family exists for the other direction: checked_add / saturating_add for accumulating durations, and checked_mul / checked_div for scaling them.

This is the integer twin of this morning’s bite 311: there, float math produced a negative that panicked at the Duration conversion; here, Duration math itself hits the floor. Either way, the fix is the same โ€” reach for the method that returns a value you can handle instead of the operator that panics.

#311 Aug 2026

311. Duration::try_from_secs_f64 โ€” Your Backoff Math Went Negative, and from_secs_f64 Panicked

Duration::from_secs_f64 panics on negative, NaN, or overflowing input โ€” exactly the values float math produces when a jittered backoff dips below zero.

Computing a delay in float land is convenient: multiply a base by a factor, subtract some jitter, done. But the conversion back to Duration is a trap:

1
2
3
4
5
6
7
8
9
use std::time::Duration;

let base: f64 = 0.2;
let jitter: f64 = 0.3;
let secs = base - jitter; // -0.1

// panics: can not convert float seconds
// to Duration: value is negative
let delay = Duration::from_secs_f64(secs);

The panic conditions are anything that isn’t representable: negative values, NaN, and anything too large for Duration. All three are one arithmetic slip away โ€” a subtraction that dips below zero, a 0.0 / 0.0 hiding in a rate calculation, an overflowing powi on the retry counter.

The fallible version hands you a Result

Duration::try_from_secs_f64 (stable since 1.66) does the same conversion but returns Err instead of panicking:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::time::Duration;

let ok = Duration::try_from_secs_f64(1.5);
assert_eq!(ok.unwrap(), Duration::from_millis(1500));

assert!(Duration::try_from_secs_f64(-0.1).is_err());
assert!(Duration::try_from_secs_f64(f64::NAN).is_err());
assert!(
    Duration::try_from_secs_f64(f64::INFINITY).is_err()
);

For backoff code, the natural move is to clamp the failure to something sane instead of crashing the retry loop:

1
2
3
4
let secs = -0.1_f64; // jitter took us negative
let delay = Duration::try_from_secs_f64(secs)
    .unwrap_or(Duration::ZERO);
assert_eq!(delay, Duration::ZERO);

There’s a try_from_secs_f32 twin for f32 inputs.

Like classify in bite 309, the point is that the float you’re holding can be quietly unrepresentable โ€” and the moment of conversion is where that surfaces. try_from_secs_f64 lets it surface as a value you can handle, not a panic in the middle of your retry loop.

#145 May 2026

145. Duration::from_nanos_u128 โ€” Round-Trip Nanoseconds Without the u64 Cast

Duration::as_nanos() hands you a u128. Duration::from_nanos() takes a u64. You feed one into the other and the compiler yells at you โ€” or worse, you cast and quietly truncate at 584 years. Rust 1.93 closed the loop with from_nanos_u128.

The mismatched-types papercut

The old API was asymmetric. Going from Duration to nanos was 128-bit:

1
2
3
4
5
use std::time::Duration;

let d = Duration::new(7, 250);
let n: u128 = d.as_nanos();
assert_eq!(n, 7_000_000_250);

Coming back, though, you only got from_nanos(_: u64) โ€” so the round-trip needed a cast:

1
2
3
4
5
use std::time::Duration;

let n: u128 = Duration::new(7, 250).as_nanos();
let back = Duration::from_nanos(n as u64); // narrowing cast, fingers crossed
assert_eq!(back, Duration::new(7, 250));

That as u64 silently truncates anything past u64::MAX โ€” and u64::MAX nanoseconds is roughly 584 years. Inside a calendar app you’ll never notice. Inside a scientific or simulation context, you absolutely will.

from_nanos_u128 matches as_nanos

Rust 1.93 stabilised Duration::from_nanos_u128, a const fn that takes the full 128-bit value:

1
2
3
4
5
use std::time::Duration;

let n: u128 = Duration::new(7, 250).as_nanos();
let back = Duration::from_nanos_u128(n);
assert_eq!(back, Duration::new(7, 250));

Same shape on both sides. No cast, no truncation, no silent wraparound.

Past the 584-year ceiling

Where the new constructor actually earns its keep is when you have nanoseconds counts that wouldn’t fit in a u64:

1
2
3
4
5
6
7
8
9
use std::time::Duration;

// 10^24 ns is ~31.7 million years โ€” well past u64::MAX nanos
let nanos: u128 = 10_u128.pow(24) + 321;
let d = Duration::from_nanos_u128(nanos);

assert_eq!(d.as_secs(), 10_u64.pow(15));
assert_eq!(d.subsec_nanos(), 321);
assert_eq!(d.as_nanos(), nanos); // exact round-trip

Duration itself stores (u64 seconds, u32 nanos), so it has plenty of room โ€” the old from_nanos was just bottlenecked by its argument type.

One thing to watch

from_nanos_u128 panics if you hand it more than Duration::MAX worth of nanoseconds. If you’re pulling values from user input or untrusted sources, guard the upper bound yourself โ€” there isn’t a checked_from_nanos_u128 (yet).

When to reach for it

Use from_nanos_u128 whenever you already have a u128 of nanoseconds โ€” typically because it came out of as_nanos, an arithmetic accumulator, or a high-precision external clock. Stick with the plain from_nanos(_: u64) for short-lived timeouts and durations measured in milliseconds or seconds; the u64 is plenty.

Stabilised in Rust 1.93 (January 2026). Available as const fn, so it works in const contexts too.