Measuring Elapsed Time in Swift with ContinuousClock

Nearly every Swift codebase has a version of this:

let start = Date()
try await doExpensiveWork()
let elapsed = Date().timeIntervalSince(start)
print("Took \(elapsed)s")

It works most of the time, which is why the failure is easy to miss. Date is wall-clock time, and wall-clock time can move backwards. NTP sync adjusts it. The user changes their timezone or sets the clock manually. Daylight saving shifts it. When any of that happens mid-measurement, your duration is wrong, and in the NTP case it can be negative.

Swift 5.7 shipped a proper answer with the Clock protocol. If you're timing code, you should be using it. Here's the version to reach for:

let start = ContinuousClock.now
try await doExpensiveWork()
let elapsed = ContinuousClock.now - start
print("Took \(elapsed)")

For the common case, that's the entire fix. The clock family does have distinctions worth knowing though, because picking the wrong one produces bugs that only show up on device, in the background, hours later.

The Three Clocks

Swift gives you two concrete clocks plus the protocol they share.

ContinuousClock monotonically increases and keeps counting while the device is asleep. It never jumps, never goes backwards, and is unaffected by the user changing the system time. This is the one you want for measuring how long something took in the real world. On Darwin it's backed by mach_continuous_time.

SuspendingClock also monotonically increases, but pauses while the device is asleep. It measures how much time the process was actually awake for. Backed by mach_absolute_time.

Date is wall-clock. It is the correct type for "when did this happen" in a human calendar sense, and the wrong type for "how long did this take."

The distinction between the first two matters more than it sounds. Consider timing a background sync:

let start = ContinuousClock.now
try await sync.run()          // device sleeps for 4 minutes partway through
let elapsed = ContinuousClock.now - start   // ~4 minutes

Versus:

let start = SuspendingClock.now
try await sync.run()          // same 4-minute sleep
let elapsed = SuspendingClock.now - start   // a few hundred milliseconds

Both are correct answers to different questions. "How long did the user wait?" is ContinuousClock. "How much execution time did this consume?" is SuspendingClock.

When in doubt, use ContinuousClock. It matches intuition about elapsed time, and it's what Task.sleep uses by default.

Duration, Not TimeInterval

Subtracting two instants gives you a Duration, not a Double. This is a genuine improvement: TimeInterval is a typealias for Double with an implied unit of seconds, and implied units are how you end up sleeping for 500 seconds instead of 500 milliseconds.

Duration is explicit at every construction site:

let a: Duration = .seconds(30)
let b: Duration = .milliseconds(250)
let c: Duration = .microseconds(500)
let d: Duration = .nanoseconds(1_000)

let total = a + b   // Durations compose

It's Comparable, Hashable, Codable, and Sendable, so it works everywhere you'd expect.

Getting a number back out is the part people fumble. There's no .seconds property. Instead there's components, which gives you exact integer seconds plus attoseconds:

let elapsed = ContinuousClock.now - start

let seconds = elapsed.components.seconds              // Int64
let attoseconds = elapsed.components.attoseconds      // Int64

Attoseconds are 10^-18, which exists so Duration can be exact rather than lossy. In practice you want a Double for logging or metrics, so add this once:

extension Duration {
    /// The duration expressed as a floating-point number of seconds.
    var seconds: Double {
        let (secs, attos) = components
        return Double(secs) + Double(attos) * 1e-18
    }

    /// The duration expressed as a floating-point number of milliseconds.
    var milliseconds: Double {
        seconds * 1_000
    }
}

Now:

logger.info("Sync took \(elapsed.milliseconds, format: .fixed(precision: 1))ms")

Formatting for Humans

Duration has built-in format styles, so you rarely need to hand-roll a string:

let d: Duration = .seconds(3_725)

d.formatted()                                   // "1:02:05"
d.formatted(.time(pattern: .hourMinuteSecond))  // "1:02:05"
d.formatted(.units(allowed: [.hours, .minutes]))// "1 hr, 2 min"
d.formatted(.units(allowed: [.seconds], width: .wide))  // "3,725 seconds"

For sub-second timings, the units style with fractional precision reads well in logs:

let fast: Duration = .milliseconds(1_234)
fast.formatted(.units(allowed: [.seconds], fractionalPart: .show(length: 2)))
// "1.23 seconds"

The measure Method

Every Clock has a measure method, which is tidier than manual start/end bookkeeping:

let elapsed = ContinuousClock().measure {
    expensiveSynchronousWork()
}

And an async overload:

let elapsed = await ContinuousClock().measure {
    try? await expensiveAsyncWork()
}

The limitation is that measure returns only the Duration and discards whatever the closure returned. That's fine for benchmarks, useless when you need the value and the timing. So write the helper that returns both:

/// Runs `body`, returning its result alongside how long it took.
func measuring<T>(
    _ body: () async throws -> T
) async rethrows -> (result: T, duration: Duration) {
    let start = ContinuousClock.now
    let result = try await body()
    return (result, ContinuousClock.now - start)
}

Which reads well at the call site:

let (landmarks, duration) = try await measuring {
    try await service.fetchAll()
}

logger.info("""
    Fetched \(landmarks.count, privacy: .public) landmarks \
    in \(duration.milliseconds, format: .fixed(precision: 1), privacy: .public)ms
    """)

Timing That Survives Errors

The helper above loses the timing if body throws, which is backwards - a slow failure is exactly what you want to know about. defer fixes it:

func timed<T>(
    _ label: String,
    _ body: () async throws -> T
) async rethrows -> T {
    let start = ContinuousClock.now
    defer {
        let elapsed = ContinuousClock.now - start
        Logger.performance.info("""
            \(label, privacy: .public) took \
            \(elapsed.milliseconds, format: .fixed(precision: 1), privacy: .public)ms
            """)
    }
    return try await body()
}

Now every call is timed whether it succeeds or not:

let landmarks = try await timed("fetchAll") {
    try await service.fetchAll()
}

I put a version of this in most projects. It's about fifteen lines and it turns "the app feels slow sometimes" into a log you can actually grep. If you haven't set up structured logging yet, the os.Logger post covers the Logger.performance pattern used above.

Deadlines and Timeouts

Clocks aren't only for measurement. Instant arithmetic gives you deadlines:

let deadline = ContinuousClock.now + .seconds(30)

while ContinuousClock.now < deadline {
    if try await pollForCompletion() { break }
    try await Task.sleep(for: .seconds(1))
}

And Task.sleep(for:) takes a Duration directly, so there's no more multiplying by a billion to get nanoseconds:

try await Task.sleep(for: .milliseconds(250))

Task.sleep also accepts an explicit clock and tolerance, which lets the system batch your wakeups to save power. If you don't need precision, say so:

try await Task.sleep(
    for: .seconds(60),
    tolerance: .seconds(10),
    clock: .continuous
)

Giving the scheduler ten seconds of slack on a one-minute sleep is nearly free and measurably better for battery. Do it for anything that isn't user-facing timing.

Benchmarking Properly

For actual benchmarks rather than production instrumentation, one measurement is noise. Run many iterations and look at the distribution:

func benchmark(
    iterations: Int = 100,
    _ body: () throws -> Void
) rethrows -> (median: Duration, min: Duration, max: Duration) {
    var samples: [Duration] = []
    samples.reserveCapacity(iterations)

    // Warm up so you're not measuring first-call overhead
    for _ in 0..<5 { try body() }

    for _ in 0..<iterations {
        samples.append(try ContinuousClock().measure(body))
    }

    samples.sort()
    return (
        median: samples[samples.count / 2],
        min: samples.first!,
        max: samples.last!
    )
}

Report the median, not the mean. A single GC pause or a page fault will skew a mean badly, and the median tells you what actually happens most of the time. The max is worth keeping too, because tail latency is what users notice.

The warm-up matters more than it looks. First-call costs include lazy initialization, dyld symbol binding, and a cold instruction cache. Measuring those once and calling it "the cost of the function" is how you end up optimizing the wrong thing.

And for anything serious, remember that a debug build tells you almost nothing. Optimizations are off, bounds checks are on, and generics aren't specialized. Benchmark in release:

swift run -c release

When Not to Use This

ContinuousClock measures wall time, which is the right thing for "how long did the user wait." It is not a profiler. If you're trying to find why something is slow rather than whether it's slow, the timing helper tells you which function to look at, and then you should reach for Instruments to see inside it. I covered that workflow in Performance Profiling with Instruments.

Also don't use a clock to schedule real calendar events. "Fire this at 9am tomorrow" is a Date and Calendar problem. Clocks measure intervals; Date locates points in human time. Using the wrong one gives you a notification that fires an hour late every time the user crosses a DST boundary.

Wrapping Up

  • Stop timing with Date(). It can move backwards and it will eventually lie to you.
  • ContinuousClock for elapsed real-world time. SuspendingClock when device sleep shouldn't count.
  • Duration is exact and explicitly united. Add a .seconds/.milliseconds extension once and reuse it.
  • Wrap timing in a defer-based helper so slow failures get measured too.
  • Task.sleep(for:) takes a Duration. Pass a tolerance for anything non-critical.
  • Benchmark in release, warm up first, report the median.

The one-line takeaway: let start = ContinuousClock.now should replace let start = Date() everywhere in your codebase that's measuring rather than timestamping.