How to Handle Monotonic Clocks in Go

Go uses monotonic clocks automatically for duration calculations via time.Now() and time.Since() to ensure accuracy despite system clock changes.

The clock that never lies

You start a timer. You process a request. You check the timer. The log shows negative five seconds. The request took negative time. The system clock jumped backward because NTP corrected the time, or a user changed the date, or a VM migrated to a host with a different clock. Your duration calculation is broken.

This is why monotonic clocks exist. A monotonic clock never goes backward. It only moves forward. It measures elapsed time since the system booted. It does not care about the year, the month, the timezone, or leap seconds. It cares only about how much time has passed.

Go makes this safe by default. You do not need to ask for a monotonic clock. You get it for free when you call time.Now(). The time package bundles wall-clock time and monotonic time together in every time.Time value. Duration math always uses the monotonic reading. System clock jumps cannot corrupt your elapsed time calculations.

Wall clock versus monotonic clock

Think of a wall clock on the kitchen wall. It tells you what time it is. You can adjust it. If you set it wrong, it shows the wrong time. This is the wall clock. It is useful for humans. It is terrible for measuring intervals.

Now think of a stopwatch. You press start. The counter goes up. You press stop. The counter shows how long you ran. The stopwatch does not care what time it is. It only measures elapsed time. This is the monotonic clock. It is useless for telling time. It is perfect for measuring intervals.

Go's time.Time struct holds both. It has the wall-clock time for display and storage. It has a hidden monotonic reading for math. When you subtract two times, Go uses the monotonic reading. The wall-clock time is ignored for the calculation. If the system clock jumps, the subtraction still gives the correct elapsed time.

Go does not expose a function like time.MonotonicNow(). This is a design choice. The raw monotonic counter is just a number. It is useless on its own. You need it attached to a moment in time so you can also print that moment. Bundling them prevents mistakes. You cannot accidentally use a monotonic counter as a timestamp, and you cannot accidentally use wall-clock time for duration math.

Convention aside: time.Now() is the source of truth. Never use os.Gettimeofday or cgo to get time. Stick to the standard library. The time package handles platform differences and monotonic readings automatically.

Minimal example

Here's the standard pattern: capture a moment, do work, measure the gap.

package main

import (
	"fmt"
	"time"
)

func main() {
	start := time.Now() // Captures wall clock and monotonic reading
	time.Sleep(100 * time.Millisecond) // Sleep uses monotonic clock
	elapsed := time.Since(start) // Subtracts monotonic readings
	fmt.Println(elapsed) // Prints duration
}

The start variable holds a time.Time. Inside that value, Go stored the current wall-clock time and the current monotonic reading. time.Sleep pauses for the duration. It uses the monotonic clock internally, so the sleep is accurate even if the system clock changes. time.Since calls time.Now() again, gets the new monotonic reading, and subtracts the old one. The result is a time.Duration.

time.Since(t) is a convenience function. It is literally time.Now().Sub(t). You can use either. time.Since reads better when you want "how long since this moment."

What happens under the hood

When you call time.Now(), Go asks the operating system for two values. It gets the wall-clock time. It gets the monotonic time. It packs both into the time.Time struct. The struct is small. Copying it is cheap. You can pass it by value without worrying about performance.

When you call t1.Sub(t2), Go checks if both times have monotonic readings. If they do, it subtracts the monotonic readings. The result is the elapsed time. If the system clock jumped between t1 and t2, the monotonic readings do not care. The subtraction is correct.

If one or both times lack a monotonic reading, Go falls back to wall-clock math. This fallback is silent. The code compiles and runs. The result might be wrong if the clock jumped. This happens when you construct a time with time.Date() or when you deserialize a time from JSON.

Convention aside: time.Time is a value type. Copying a time copies the monotonic reading. This is good. You can store a time in a struct, pass it around, and the monotonic reading travels with it. You do not need pointers to preserve the stopwatch.

Realistic example

Here's how a latency logger uses this safely.

package main

import (
	"fmt"
	"net/http"
	"time"
)

// logLatency wraps a handler and prints the request duration.
func logLatency(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		start := time.Now() // Record start for both logging and timing
		next(w, r)
		duration := time.Since(start) // Calculate duration using monotonic clock
		fmt.Printf("Request took %v\n", duration)
	}
}

func main() {
	http.HandleFunc("/", logLatency(func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(50 * time.Millisecond) // Simulate work
		w.Write([]byte("OK"))
	}))
	fmt.Println("Server starting")
	http.ListenAndServe(":8080", nil)
}

The middleware captures start before calling the handler. After the handler returns, it calculates the duration. The duration uses the monotonic clock. If NTP adjusts the system clock during the request, the logged duration is still accurate. The wall-clock time is available if you need to log the timestamp, but the duration math is safe.

Trust time.Since. It handles the math correctly.

Pitfalls and gotchas

Monotonic clocks are safe, but they have rules. Breaking the rules leads to subtle bugs.

Comparison checks the stopwatch

The == operator on time.Time compares both wall-clock time and monotonic reading. Two times can represent the same instant but not be equal.

t1 := time.Now()
t2 := time.Now()
// t1 and t2 have same wall time but different monotonic readings
if t1 == t2 {
	fmt.Println("Equal") // Never prints
}
if t1.Equal(t2) {
	fmt.Println("Wall time equal") // Prints if wall time matches
}

The == operator checks the monotonic reading. If you call time.Now() twice, the monotonic readings differ. The times are not equal. Use t1.Equal(t2) to compare wall-clock times and ignore monotonic readings. This is a common source of confusion. Two times can represent the same instant but not be equal.

Why does == check monotonic? Because time.Time is a value. If you copy a time, the copy should be equal to the original. If == ignored monotonic, you could have two times that are equal but have different internal state, which breaks value semantics. Equal is the method for wall-clock comparison.

Comparison checks the stopwatch. Use Equal if you only care about the wall clock.

Serialization kills monotonic readings

When you marshal a time.Time to JSON, the monotonic reading is stripped. The JSON output contains only the wall-clock time. When you unmarshal the JSON back into a time.Time, the result has no monotonic reading.

t := time.Now()
// Marshal to JSON drops monotonic reading
// Unmarshal creates time with no monotonic reading
// Math on this time falls back to wall clock

If you do duration math on a time that came from JSON, Go falls back to wall-clock math. If the system clock jumped, the result is wrong. This fallback is silent. The compiler does not warn you. The code runs. The result might be incorrect.

Avoid doing duration math on times that came from JSON. If you need to measure elapsed time across a network boundary, send the duration, not the timestamp. Or use a protocol that supports monotonic readings, though most do not.

Serialization kills monotonic readings. If you round-trip through JSON, you lose the stopwatch.

Cross-process comparison is meaningless

The monotonic clock is per-process. Each process has its own monotonic counter. Comparing time.Now() from process A to time.Now() from process B is meaningless for duration. The monotonic readings are from different stopwatches.

If you need to coordinate timing across processes, use wall-clock time. Understand that wall-clock time can jump. Use protocols like NTP to keep clocks synchronized. Or use a centralized time source.

Monotonic clocks are local. Do not compare times from different processes.

Decision matrix

Use time.Now() when you need a timestamp for logging, display, or storage.

Use time.Since() when you need the elapsed duration between a past moment and now.

Use time.Sleep() when you need to pause execution for a duration.

Use t1.Equal(t2) when you need to compare wall-clock times and ignore monotonic readings.

Use time.Unix() when you need a wall-clock-only timestamp for interoperability with external systems.

Use time.Date() when you need to construct a time from components, understanding that the result has no monotonic reading.

Use time.Now().UTC() when you need a UTC timestamp for storage, knowing the monotonic reading is preserved.

Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Where to go next