Skip to content
Software Atlas

Time, Clocks & Ordering

Why two machines can't agree on what time it is, and what to do about it: drift and NTP, wall vs monotonic clocks, happened-before, Lamport and vector clocks, hybrid logical clocks, and Spanner's TrueTime.

⏱ ~45 min read14 sections10 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceEvery computer's clock is slightly wrong, each in its own way. So timestamps from different machines can't reliably tell you what happened first. When order matters, you track cause and effect instead.

Here's a bug you can meet in your first month on a backend team. A customer pays for order #42 and cancels a moment later. The payments server charges the card and sends a message to the refunds server, which refunds the money. Both servers write log lines, and a log tool merges them, sorted by timestamp. Drag the slider:

Two servers, one log
The payments server charges a card and tells the refunds server, which then refunds it. A log tool collects both servers' lines and sorts them by timestamp.
What really happenedreal time →
.000.050.100.150.200.250payments (clock correct)refunds (clock 80 ms slow)chargerefund
The log, sorted by timestamp
  1. 1. 12:00:00.050 [refunds]
    got message “order #42 charged”
  2. 2. 12:00:00.070 [refunds]
    refunded $40 for order #42
  3. 3. 12:00:00.100 [payments]
    charged $40 for order #42
The log says the refund came before the charge, which is impossible: the refund was caused by the charge. Nothing is broken except that the refunds server's clock is 80 ms slow. An auditor (or a bug hunter at 3 a.m.) reading this log will be misled.

The refund was caused by the charge, so it must have come after it. Yet the log shows it first. Which server's clock was right? Neither, exactly. Both clocks were a little off, and when two events are only milliseconds apart on different machines, “a little off” is enough to flip their order. The difference between two machines' clocks is called clock skew.

Three ways to deal with time

This page is about how a distributed system (a program spread across several machines, each called a node) copes with time. There are three families of answers:

1

Better physical clocks

Know which clock to read (§03), keep clocks close with time sync (§02, §04), and know how big the error can still be.
2

Logical clocks

Stop asking “what time is it?” and count events and messages instead. Lamport clocks and vector clocks track cause and effect exactly (§05–§08).
3

Hybrids

Combine the two: timestamps that look like real time but still respect cause and effect (HLC, §09), or clocks with a known error bound that you wait out (TrueTime, §10).
Foundations§02

Why computer clocks disagree

In one sentenceA computer keeps time by counting the vibrations of a tiny crystal. No two crystals vibrate at exactly the same speed, so every clock drifts, and time sync has to keep pulling them back.

The time a computer “knows”

When your code asks for the current time (Date.now() in JavaScript, time.Now() in Go), it reads the wall clock: the computer's belief about the time of day. Servers keep it in UTC (no time zones, no daylight saving), and code usually handles it as Unix time: the number of milliseconds since 1 January 1970, UTC. For example, Date.now() might return 1793361600000.

Drift: every clock runs at its own speed

The wall clock is driven by a quartz oscillator, a crystal that vibrates millions of times per second. The computer counts vibrations: “this many = one second”. But each crystal is a tiny bit faster or slower than its label says, and the speed changes with temperature. How far a clock wanders is its drift, measured in ppm (parts per million).

Worked example. 1 ppm means gaining or losing 1 microsecond (µs, a millionth of a second) every second. A day has 86,400 seconds. So a server whose clock runs 20 ppm fast gains 20 × 86,400 µs ≈ 1.7 seconds per day. Typical server crystals are somewhere around 10–100 ppm off (a rough range, it depends on the hardware and the temperature), which means seconds per day. Two servers drifting in opposite directions pull apart even faster: their rates add up (+40 ppm and −25 ppm are 65 ppm apart).

Time sync pulls clocks back

Left alone, clocks would be minutes apart within weeks. So every server runs a time-sync program that regularly asks a more accurate time server “what time is it?” and corrects the local clock. The standard protocol is NTP; §04 shows how it works. The interesting part here is how it corrects a wrong clock. There are two ways:

  • Step: jump the clock straight to the right time. Fast, but if the clock was ahead, it jumps backwards, and any code reading the clock sees time run in reverse.
  • Slew: make the clock tick slightly slower (or faster) until the error is gone. Time never jumps, but a big error takes a long time to fix. A classic limit is 500 ppm, i.e. 0.5 ms of correction per second, so fixing a 1-second error takes about 2,000 seconds (half an hour).

Try both. Server A drifts +40 ppm (fast), server B −25 ppm (slow):

Drift simulator
Two servers start the day with perfect clocks. Each one's quartz runs a little fast or slow. Turn time sync on and compare stepping with slewing.
clock error (how far ahead of real time)−3.50 s0+3.50 s00:0006:0012:0018:0024:00AB
Server A
+1.73 s
Server B
−1.08 s
Skew between them
2.81 s
Without sync, errors only grow: 40 ppm is 3.46 s per day. Two servers drifting in opposite directions pull apart even faster. Turn on NTP.

Three things to notice:

  1. Without sync, the skew grows without limit: drag Look at time of day to 24:00 and the two servers are about 5.6 seconds apart.
  2. With sync, the error stays small but never reaches zero. Between corrections, both clocks drift again. Real NTP corrects every few minutes, not every two hours, so real errors are milliseconds, not hundreds of milliseconds. But they're never zero.
  3. Stepping a fast clock makes time go backwards. Any code that computes “now minus earlier” across that moment gets a negative number. §03 shows how to protect yourself.

Leap seconds: when the official time itself jumps

The Earth's rotation is slightly irregular, so the official world time, UTC, occasionally adds a leap second: the last minute of a day gets 61 seconds, ending in 23:59:60. It has happened 27 times since 1972, most recently at the end of 2016. Unix time has no way to write 23:59:60, so systems traditionally repeated a second, which means the clock stepped back by one second.

Software handled this badly. The 2012 leap second set off a Linux kernel bug that sent many servers to 100% CPU, and the 2016 one crashed part of Cloudflare's DNS service (§03 tells that story). Today, big providers such as Google and AWS use a leap smear: their time servers run very slightly slow for many hours (Google smears over 24 hours), absorbing the extra second so no clock ever jumps. In 2022, the world's timekeeping authorities voted to stop adding leap seconds by 2035.

Foundations§03

Wall clock vs monotonic clock

In one sentenceYour computer has two clocks. The wall clock tells you what time it is. The monotonic clock tells you how much time has passed. Use the second one for every duration and timeout.

A monotonic clock is the computer's stopwatch: a counter that only moves forward at a steady rate, usually counting from when the machine booted. Time sync (§02) never steps it. Its value means nothing on its own (“5,000.2 seconds since boot” isn't a time of day), and it means nothing on another machine. It's only good for one thing: end − start, inside one running program (one OS process).

Here's why that matters. Drag the slider to change the size of the NTP step:

Measuring a 2-second job
A job starts, runs for exactly 2 real seconds, and ends. Halfway through, NTP corrects the wall clock with a step. Both clocks are read at the start and the end.
job: 2 real secondsNTP steps wall clock −3.0 swall clock12:00:00.00011:59:59.000monotonic5000.000 s5002.000 s
Wall clock saysDate.now() · time read from JSON
-1.0 s
A negative duration. A timeout check like “elapsed > 5 s” never fires, and code that does math with it can crash.
Monotonic clock saysperformance.now() · time.Since
2.0 s
Always right. NTP never jumps the monotonic clock, so end − start is the real time that passed.

Measuring a duration, correctly

elapsed.go
package main

import (
	"fmt"
	"time"
)

func main() {
	start := time.Now()              // carries BOTH a wall reading and a monotonic reading
	fmt.Println(start)               // 2026-10-30 12:34:47.996327924 +0000 UTC m=+0.000042757

	time.Sleep(50 * time.Millisecond)

	elapsed := time.Since(start)     // ← uses the monotonic readings: immune to clock steps
	fmt.Println(elapsed)             // 51.141069ms
}

When the monotonic reading disappears

In Go, the monotonic reading lives only inside the running process. Several common operations drop it silently, and then Sub falls back to wall-clock math:

b, _ := json.Marshal(start) // "2026-10-30T12:34:47.996327924Z" ← wall time only (error ignored for brevity)
s := start.Round(0)         // s: same time, monotonic reading stripped (that's the idiom for it)
u := start.UTC()            // u: converting the time zone also strips it

start itself is unchanged: a time.Time is a plain value, and these return new values. So a time.Time you read back from JSON, a database or another service is wall time only. Subtracting it from time.Now() is a wall-clock measurement, with all the jumps that implies. That's fine for “roughly how old is this record?”, and wrong for timeouts.

The bug that crashed a DNS service

At midnight UTC on 1 January 2017, a leap second was added (§02) and some clocks repeated a second. Cloudflare's DNS software, written in Go, measured how long upstream servers took to answer by subtracting two wall-clock readings. Across the leap second, one of those durations came out negative, and the code passed it to rand.Int63n, which panics on a negative number. Part of their DNS service went down for a while. At the time, Go's time.Now() had no monotonic reading; it gained one in Go 1.9 (August 2017), partly because of incidents like this.

Go's timers, time.Sleep and context.WithTimeout already use the monotonic clock, and so do setTimeout and setInterval in Node and modern browsers, so those are safe. The bugs come from code that stores Date.now() or a deserialized time and subtracts later. (One caveat: on some systems the monotonic clock pauses while the machine is asleep, so a laptop that slept for an hour may not count that hour.)

Practitioner§04

NTP in practice

In one sentenceNTP estimates your clock's error from four timestamps and one assumption: the request and the reply took equally long. The assumption is never quite true, so the estimate is never quite right.

One exchange, four timestamps

The client sends a request and notes the time on its own clock (t1). The server notes when it arrived (t2) and when it sent the reply (t3), both on the server's clock. The client notes when the reply arrived (t4), on its own clock again. From those four numbers:

  • Delay δ = (t4 − t1) − (t3 − t2): the whole round trip minus the time the server spent thinking. That's the network part, the round-trip time (RTT).
  • Offset θ = ((t2 − t1) + (t3 − t4)) / 2: how much the client should add to its clock.

Worked example. The client's clock is 50 ms behind. Each direction takes 10 ms, and the server needs 1 ms. The client sends at what its clock calls 950 (real time 1000). It arrives at real time 1010, so t2 = 1010; the server replies at t3 = 1011. The reply arrives at real time 1021, which the client's clock calls t4 = 971.

  • δ = (971 − 950) − (1011 − 1010) = 21 − 1 = 20 ms of network.
  • θ = ((1010 − 950) + (1011 − 971)) / 2 = (60 + 40) / 2 = +50 ms. Exactly right: the client is 50 ms behind.

Now make the two directions different:

One NTP exchange
The client asks the server for the time. Four timestamps, each read on that machine's own clock, are enough to estimate the client's error.
server ✓clientt1 = 950t2 = 1010t3 = 1011t4 = 97110 ms10 ms
Estimated correction (offset θ)
((t2 − t1) + (t3 − t4)) / 2
= ((1010 − 950) + (1011 − 971)) / 2
= +50 ms
Round-trip delay δ
(t4 − t1) − (t3 − t2)
= (971 − 950) − (1011 − 1010)
= 20 ms
The true correction is +50 ms and NTP estimated exactly that, because the request and reply took the same time. Now make one direction slower.

When the request takes 30 ms and the reply 10 ms, the estimate is off by 10 ms: half the difference between the two directions. NTP can't see that difference; from the client's point of view both cases look the same. The worst possible error is half the round trip (δ / 2). That's why NTP over the internet, with long and lopsided routes, is much less precise than NTP inside a data center.

A real time-sync program

One exchange is noisy, so a real NTP client like chrony (the default on most Linux distributions) talks to several servers every few minutes, throws out outliers, and prefers exchanges with a small round trip. Two more ideas make it good:

  • It learns your crystal's drift. After a while chrony knows “this clock runs 12 ppm slow” and continuously speeds it up by that much, so the error between syncs stays small. That's the Frequency line below.
  • It mostly slews, and only steps when it must (§02). A common default config line is makestep 1.0 3: step only if the error is over 1 second, and only during the first 3 updates after boot. After that, everything is slewed, so time never jumps backwards.
$ chronyc tracking            # illustrative output, abridged
Reference ID    : A9FEA97B (169.254.169.123)
Stratum         : 4
System time     : 0.000012345 seconds fast of NTP time
Frequency       : 12.345 ppm slow
Root delay      : 0.000250000 seconds
Leap status     : Normal

Stratum is the server's stratum: how many hops it is from a reference clock such as an atomic clock or a GPS receiver. System time is the current estimated error (12 µs here). Root delay is the total round trip all the way up to the reference clock, which bounds how wrong the estimate can be. On a machine without chrony, timedatectl at least tells you whether the clock is synchronized.

How close can clocks get?

These are rough, typical figures, not guarantees:

SetupTypical errorNotes
No sync at allseconds per day, growing§02
NTP over the public internet~1–50 mslong, lopsided routes
NTP inside a data center or cloud region~0.01–1 msshort, symmetric network paths
PTP with hardware timestamping≲ 1 µsneeds network cards and switches that support it
GPS receiver or atomic clock on site~0.1 µswhat reference clocks are made of

In the cloud, use the provider's time service instead of public servers: AWS offers the Amazon Time Sync Service at 169.254.169.123 (and, on some instance types, a PTP hardware clock with microsecond-level accuracy), and Google Cloud VMs sync from metadata.google.internal. Both apply a leap smear (§02).

Even the best setup leaves some error. The next section asks a different question: if clocks can't be trusted to order events across machines, what can?

Foundations§05

Order without clocks: happened-before

In one sentenceForget what time it was. Ask instead: could event A have influenced event B? If yes, A happened before B. If neither could have influenced the other, they're concurrent, whatever the clocks say.

In 1978, Leslie Lamport wrote a short paper, Time, Clocks, and the Ordering of Events in a Distributed System, that changed how engineers think about time. His observation: between machines, we can't know which event happened at an earlier instant. But we usually don't need to. What we need is: did one event know about the other? A refund that was triggered by a charge must be ordered after the charge. Two unrelated events on different servers can go in either order and nobody can tell the difference.

The three rules

Lamport called each participant a process: a server, a phone, anything that does things one at a time. In this section and the next two we keep his word; it means the same as node from §01, and it is not the operating-system process of §03 (one machine could even run several). An event is anything a process does: a local step, sending a message, or receiving one. Lamport defined happened-before, written a → b, with three rules:

  1. Same process: if a and b happen on the same process and a comes first, then a → b.
  2. Messages: if a is “send message m” and b is “receive m”, then a → b.
  3. Chains: if a → b and b → c, then a → c.

That's all. If neither a → b nor b → a, the events are concurrent, written a ∥ b.

See it on a diagram

Distributed events are drawn as a space-time diagram: one horizontal line per process (time flows left to right), a dot per event, an arrow per message. The horizontal position only shows each process's own order; dots on different lines that line up vertically aren't “at the same time”.

Click any event. Its past (every event that happened before it) lights up violet, its future cyan, and the concurrent ones go hollow. To find the past by hand, start at the event and walk backwards: left along its own line, and back along any arrow that ends on that line.

Space-time diagram
Three nodes (Lamport would say processes) A, B, C. Time flows left to right. Add events and messages; deliver a message whenever you like. Click events to explore.
ABCm1m2m3A1A2B1B2C1B3C2C3A3
node A
node B
node C
Click an event to see its past and future. Click two events to compare them.

Try these with the example loaded:

  • Click B1. Nothing lights up on A at all: B1 happened before B received m1, so it knows nothing about A. A1 and A2 are concurrent with B1.
  • Click C3. Its past includes A1 and A2, even though C never talked to A directly: A2 → B2 (message m1), B2 → B3 (same process), B3 → C3 (message m2). That's rule 3.
  • Click A3 and B3 together: concurrent. Each has news the other lacks.
  • Add your own events and messages; leave a message in flight, then deliver it and watch the receiver's later events gain a bigger past.

Why this is the right kind of order

Happened-before is exactly the order you can't afford to get wrong: a reply after its question, a refund after its charge, a delete after the create it deletes. For concurrent events, no observer could have told which came first, so any order is defensible, as long as every machine picks the same one. It's a partial order: some pairs are ordered, some aren't.

To use it in code, a machine needs to attach something to each event that captures happened-before. Clocks can't (§01). The next two sections build counters that can.

Practitioner§06

Lamport clocks

In one sentenceA Lamport clock is one counter per process. Add 1 for every event; on receive, jump past the sender's number. If a happened before b, then a's number is smaller.

The rules

A Lamport clock is simple: each process keeps a single integer, starting at 0:

// local event or send:
time = time + 1            // and a send attaches `time` to the message
// receive a message that carries `remote`:
time = Math.max(time, remote) + 1

The max is the whole trick. Receiving a message means “I now know about everything the sender knew”, so the receiver's counter jumps to at least the sender's number, then counts the receive itself.

Worked example (the diagram below, which is the same scenario as §05). A has a local event (A1 = 1) and then sends m1 (A2 = 2, the message carries 2). Meanwhile B has a local event (B1 = 1). When m1 arrives, B computes max(1, 2) + 1 = 3. The receive gets a bigger number than the send, as it must.

Space-time diagram: Lamport clocks
Three nodes (Lamport would say processes) A, B, C. Time flows left to right. Add events and messages; deliver a message whenever you like. Click events to explore.
ABCm1m2m31A12A21B13B21C14B32C25C33A3
node A
node B
node C
last step · A3: A receives m3, which carries 2. A's clock was 2. New value = max(2, 2) + 1 = 3.
sorted by (Lamport number, server name): A1(1) B1(1) C1(1) A2(2) C2(2) A3(3) B2(3) B3(4) C3(5)
Click an event to see its past and future. Click two events to compare them.

Every message arrow goes from a smaller number to a bigger one, and numbers grow along each line. Follow those two facts through the three rules of §05 and you get the clock condition:

If a → b, then L(a) < L(b).

A total order for free

Sometimes you need every machine to agree on one order of all events, even concurrent ones. For example, to decide which of two conflicting requests to apply first, without asking anyone. Sort by the Lamport number, and break ties by process name: (1, A) < (1, B) < (1, C) < (2, A) … The line under the diagram shows this order. It's a total order, and it's consistent with happened-before: whenever a → b, a comes first in the list.

Lamport's paper used exactly this to build a lock that several machines share without a central server. Today you meet it in places like collaborative-editing libraries (Automerge, for example, gives each change an ID made of a counter and the author's ID, and sorts them the same way) and as a tie-breaker in conflict resolution.

What a Lamport clock can't tell you

The clock condition only works in one direction. L(a) < L(b) does not mean a → b. In the example, click B1 (1) and A2 (2): 1 < 2, yet they're concurrent. Click C2 (2) and B2 (3): same story. From two numbers alone, you can't tell “a happened before b” apart from “a and b are concurrent”. What you can conclude:

  • L(a) < L(b) → b did not happen before a. (Either a → b, or they're concurrent.)
  • L(a) = L(b) on different processes → they're concurrent.

Two more limits:

  • The numbers have nothing to do with real time. A busy server's counter runs ahead of a quiet one's. You can't ask “what happened around 12:00?”
  • The total order is arbitrary for concurrent events. It's consistent and everyone agrees on it, but “A wins ties because its name sorts first” isn't the same as “A was really first”.

If you need to detect concurrency, for example to notice that two users edited the same document without seeing each other's change, one number per event isn't enough. You need one number per process. That's §07.

Practitioner§07

Vector clocks

In one sentenceA vector clock keeps one counter per process. It records “how many events of each process do I know about?”, and that's enough to tell before, after and concurrent apart.

The rules

A vector clock generalizes the Lamport counter. With three processes A, B and C, every process keeps a list of three numbers, [A, B, C], all starting at 0:

  1. Local event or send: add 1 to your own entry. A send attaches a copy of the whole vector to the message.
  2. Receive: take the bigger number in each position (yours vs. the message's), then add 1 to your own entry.

Entry B in A's vector means: “I know about the first that many events on B.” Receiving a message passes on everything the sender knew, which is why you take the maximum position by position.

Worked example. A1 = [1,0,0], A2 = [2,0,0] (sent as m1). B1 = [0,1,0]. When m1 arrives at B: max of [0,1,0] and [2,0,0] is [2,1,0]; add 1 to B's entry → B2 = [2,2,0]. B2 now “knows” A's first two events, which is exactly its past.

Comparing two vectors

ComparisonMeaning
Every entry of x ≤ the same entry of y, and they're not equalx happened before y
Every entry of y ≤ the same entry of x, and they're not equaly happened before x
x is bigger somewhere and y is bigger somewhere elsex and y are concurrent

For example, A3 = [3,0,2] and B3 = [2,3,0]. A3 is bigger in A's entry, B3 in B's entry. Each knows something the other doesn't: concurrent. Unlike Lamport numbers, this comparison is exact in both directions: x → y if and only if x's vector is ≤ y's (and different).

Switch between the three modes on the same diagram. Click two events in vector mode and the box explains the comparison; flip to Lamport with the same two selected to see what a single number loses:

Space-time diagram: vector clocks
Three nodes (Lamport would say processes) A, B, C. Time flows left to right. Add events and messages; deliver a message whenever you like. Click events to explore.
ABCm1m2m31,0,0A12,0,0A20,1,0B12,2,0B20,0,1C12,3,0B30,0,2C22,3,3C33,0,2A3
node A
node B
node C
last step · A3: A receives m3 carrying [0,0,2]. A's vector was [2,0,0]. Take the bigger number in each position → [2,0,2], then add 1 to A's own entry → [3,0,2].
Click an event to see its past and future. Click two events to compare them. Each label lists the counters in the order [A, B, C].

What it's for: detecting conflicts

The classic use is spotting conflicting writes in a database with several copies. Each stored value carries a vector. When two versions of the same key meet:

  • If one vector is ≤ the other, the newer version simply replaces the older one: its writer had already seen the older value.
  • If they're concurrent, two clients wrote without seeing each other's change. That's a real conflict. Keep both versions (called siblings) and let the app merge them, instead of silently throwing one away.

The Replication topic shows where these conflicts come from and the strategies for resolving them. Databases usually attach a version vector to each value: the same comparison, but counting something different. A vector clock ticks on every event of a process; a version vector ticks only when a replica writes that key, so it has one counter per replica that wrote the value. Riak, for example, uses version vectors.

The cost: one entry per process

A vector clock has one entry per participant. With 3 servers that's tiny. With every browser tab or phone as a participant, vectors grow to thousands of entries and travel with every message and every stored value. Real systems fight this:

  • Count servers, not clients. Only the replicas that coordinate writes get entries.
  • Prune old entries. Riak historically trimmed vectors past a size limit. That's dangerous: once an entry is gone, two versions can look concurrent when they weren't (a false conflict), or ordered when they weren't (silently losing a write).
  • Better encodings, such as dotted version vectors, keep the size proportional to the replicas while still detecting conflicts correctly.
Practitioner§08

Building them in Go and JavaScript

Logical clocks are small enough to write yourself, and writing them is the best way to remember the rules. All the code below was run with Go 1.24 and Node 22, and every test passes, including under Go's race detector (go test -race, which reports two goroutines touching the same memory unsafely).

Lamport clock

lamport.go
package clocks

import "sync"

// Lamport is a Lamport clock: one counter per node.
type Lamport struct {
	mu   sync.Mutex // a server handles many requests at once
	time uint64
}

// Tick: call for every local event and before every send.
// The result is the event's timestamp (attach it to the message).
func (l *Lamport) Tick() uint64 {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.time++
	return l.time
}

// Recv: call when a message arrives, with the timestamp it carried.
func (l *Lamport) Recv(remote uint64) uint64 {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.time = max(l.time, remote) + 1 // ← jump past the sender, then count this event
	return l.time
}

Vector clock

vector.go
package clocks

import (
	"maps"
	"sync"
)

// VClock maps a node ID to how many of that node's events we know about.
type VClock map[string]uint64

type Order int

const (
	Equal      Order = iota // 0
	Before                  // 1
	After                   // 2
	Concurrent              // 3
)

// String makes test failures print "Before" instead of "1".
func (o Order) String() string {
	return [...]string{"Equal", "Before", "After", "Concurrent"}[o]
}

// Compare says how a relates to b.
func Compare(a, b VClock) Order {
	aAhead, bAhead := false, false
	for id, n := range a {
		if n > b[id] { // a missing key reads as 0
			aAhead = true
		}
	}
	for id, n := range b {
		if n > a[id] {
			bAhead = true
		}
	}
	switch {
	case aAhead && bAhead:
		return Concurrent // each knows something the other doesn't
	case aAhead:
		return After
	case bAhead:
		return Before
	default:
		return Equal
	}
}

// Node owns one vector clock. Safe for concurrent use.
type Node struct {
	mu    sync.Mutex // Go maps crash on concurrent writes, so guard the clock
	id    string
	clock VClock
}

func NewNode(id string) *Node { return &Node{id: id, clock: VClock{}} }

// Event: a local event or a send. Returns a copy to store or attach to a message.
func (n *Node) Event() VClock {
	n.mu.Lock()
	defer n.mu.Unlock()
	return n.tick()
}

// Recv merges the sender's vector, then counts the receive as an event.
func (n *Node) Recv(remote VClock) VClock {
	n.mu.Lock()
	defer n.mu.Unlock()
	for id, c := range remote {
		n.clock[id] = max(n.clock[id], c) // ← element-wise max
	}
	return n.tick() // not Event(): we already hold the lock
}

// tick adds 1 to our own entry. The caller must hold n.mu.
func (n *Node) tick() VClock {
	n.clock[n.id]++
	return maps.Clone(n.clock)
}

Tests

The tests replay the start of the §05 scenario: A has two events (the second is a send), B has one local event and then receives.

clocks_test.go
package clocks

import (
	"sync"
	"testing"
)

func TestLamport(t *testing.T) {
	var a, b Lamport // zero value = clock at 0, ready to use

	a.Tick()        // A: local event → 1
	msg := a.Tick() // A: send        → 2
	b.Tick()        // B: local event → 1

	if got := b.Recv(msg); got != 3 { // max(1, 2) + 1
		t.Fatalf("receive = %d, want 3", got)
	}
}

func TestVector(t *testing.T) {
	a, b := NewNode("A"), NewNode("B")

	a1 := a.Event()   // {A:1}
	msg := a.Event()  // {A:2}        sent to B
	b1 := b.Event()   // {B:1}        B hasn't heard from A yet
	b2 := b.Recv(msg) // {A:2, B:2}

	tests := []struct {
		name string
		x, y VClock
		want Order
	}{
		{"send before receive", msg, b2, Before},
		{"a1 before a2", a1, msg, Before},
		{"b1 and a1 are concurrent", b1, a1, Concurrent},
		{"receive after b1", b2, b1, After},
	}
	for _, tc := range tests {
		if got := Compare(tc.x, tc.y); got != tc.want {
			t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
		}
	}
}

// 50 goroutines use one node at once, like requests in a server.
func TestConcurrentUse(t *testing.T) {
	n := NewNode("A")
	var wg sync.WaitGroup
	for i := 0; i < 50; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			n.Event()
			n.Recv(VClock{"B": 3})
		}()
	}
	wg.Wait()
	if got := n.Event()["A"]; got != 101 { // 50 events + 50 receives + this one
		t.Fatalf("A = %d, want 101", got)
	}
}
$ go test -race -v ./...
=== RUN   TestLamport
--- PASS: TestLamport (0.00s)
=== RUN   TestVector
--- PASS: TestVector (0.00s)
=== RUN   TestConcurrentUse
--- PASS: TestConcurrentUse (0.00s)
PASS

$ node --test
# tests 2
# pass 2
# fail 0

Carrying the clock in messages

A clock only works if its value travels with every message: in an HTTP header, in gRPC metadata, or as a field in the message body. The pattern is always the same: call Tick() (or Event()) just before sending and put the result in the message; call Recv(…) with the received value before doing anything else with the message. Forget one path (a background job, a retry) and causality silently breaks for that path.

Staff-level§09

Hybrid logical clocks (HLC)

In one sentenceA hybrid logical clock is a wall-clock timestamp with a small counter attached. It stays within the clock skew of real time, never goes backwards, and, like a Lamport clock, always orders a receive after its send.

Why not just pick one?

So far we have two kinds of timestamp, each with a flaw:

Physical timestampLamport clock
Close to real time?✓ “12:00:00.205” means something to a human✗ just a counter
Respects happened-before?✗ a receive can be stamped before its send (§01)✓ always
Never goes backwards?✗ it can, when the clock is stepped (§02)✓ never

A database wants both. It wants to answer “show me the data as of 12:00” (which needs real time) while guaranteeing that a write which caused another write gets a smaller timestamp (which needs causality). In 2014, Sandeep Kulkarni and colleagues published the hybrid logical clock, which gets both.

The algorithm

An HLC timestamp is a pair (l, c). l is a wall-clock time in milliseconds: the biggest physical time this node has heard of. c is a counter that orders events sharing the same l. Timestamps compare by l first, then c. Each node also reads its physical clock, pt.

  • Local event or send: l = max(l, pt). If l didn't change (the physical clock hasn't passed it yet), add 1 to c; otherwise set c = 0.
  • Receive a message carrying (ml, mc): l = max(l, ml, pt). Then set c based on which of the three won:
    • l equals both the old l and ml → c = max(c, mc) + 1
    • only the old l → c = c + 1
    • only ml → c = mc + 1
    • pt (the physical clock is ahead of both) → c = 0

Compare it with Lamport's rule, max(mine, received) + 1: the HLC does the same thing, except that the physical clock also takes part in the max, and “+1” happens in the counter instead of in the time. Step through it. Server A's clock is 100 ms fast:

Hybrid logical clock, step by step
Server A's physical clock is 100 ms fast; B's is correct. Each event gets an HLC timestamp (clock part, counter). Under each dot: the physical clock reading.
A (+100 ms)B
step 0 / 8
Press Next. Rules: for a local event or send, l = max(l, physical clock); for a receive, l = max(l, message's l, physical clock). The counter c only breaks ties when l didn't change.

Step 4 is the whole point. B's physical clock said 110, earlier than the send it was receiving (206). A plain timestamp would have recorded an effect before its cause. The HLC took 206 from the message and bumped the counter, so the receive is ordered after the send, and its l is still a real wall-clock reading (A's), not an arbitrary number.

The guarantees

  • Respects happened-before: if a → b, then HLC(a) < HLC(b), like a Lamport clock.
  • Never goes backwards, even if NTP steps the physical clock back: l only grows.
  • Stays close to real time: l is always at least the node's own physical clock, and at most the fastest clock in the cluster. So it's never further from physical time than the worst clock skew.
  • Small: the counter only grows while the physical clock is behind l, so it stays small, and the pair fits in 64 bits (the original paper used 48 bits of time and 16 bits of counter). Our Go struct below is bigger, for readability; real systems pack it.

In Go

The physical clock is passed in as a function, so tests can control it (§11 explains why):

hlc.go
package clocks

import (
	"fmt"
	"sync"
	"time"
)

// Timestamp is an HLC reading: a wall-clock part plus a tie-breaking counter.
type Timestamp struct {
	Wall    int64  // milliseconds since the Unix epoch (close to real time)
	Logical uint32 // counts events that share the same Wall value
}

func (a Timestamp) Less(b Timestamp) bool {
	return a.Wall < b.Wall || (a.Wall == b.Wall && a.Logical < b.Logical)
}

type HLC struct {
	mu        sync.Mutex
	now       func() int64  // physical clock, injectable for tests
	maxOffset time.Duration // reject peers whose clock is further ahead than this
	last      Timestamp
}

func NewHLC(now func() int64, maxOffset time.Duration) *HLC {
	return &HLC{now: now, maxOffset: maxOffset}
}

// Now: for a local event or a send.
func (h *HLC) Now() Timestamp {
	h.mu.Lock()
	defer h.mu.Unlock()
	pt := h.now()
	if pt > h.last.Wall {
		h.last = Timestamp{Wall: pt} // physical clock moved on: use it, counter back to 0
	} else {
		h.last.Logical++ // clock didn't move (or went back): stay put, count up
	}
	return h.last
}

// Update: for a receive. Never moves backwards, never falls behind the sender.
func (h *HLC) Update(remote Timestamp) (Timestamp, error) {
	h.mu.Lock()
	defer h.mu.Unlock()
	pt := h.now()
	if d := time.Duration(remote.Wall-pt) * time.Millisecond; d > h.maxOffset {
		return Timestamp{}, fmt.Errorf("remote clock %v ahead of ours, max is %v", d, h.maxOffset)
	}
	old := h.last
	wall := max(old.Wall, remote.Wall, pt)
	var logical uint32
	switch {
	case wall == old.Wall && wall == remote.Wall:
		logical = max(old.Logical, remote.Logical) + 1
	case wall == old.Wall:
		logical = old.Logical + 1
	case wall == remote.Wall:
		logical = remote.Logical + 1
	default: // our physical clock is ahead of both: counter resets
		logical = 0
	}
	h.last = Timestamp{Wall: wall, Logical: logical}
	return h.last, nil
}

And a test with a fake physical clock, covering step 4 of the stepper, a clock stepped backwards, and a peer whose clock is far ahead:

hlc_test.go
package clocks

import (
	"testing"
	"time"
)

// fakeClock is a physical clock the test controls.
type fakeClock struct{ ms int64 }

func (f *fakeClock) now() int64 { return f.ms }

func TestHLCFollowsSenderWithSlowClock(t *testing.T) {
	aClock, bClock := &fakeClock{ms: 206}, &fakeClock{ms: 106} // B is ~100 ms behind
	a := NewHLC(aClock.now, 500*time.Millisecond)
	b := NewHLC(bClock.now, 500*time.Millisecond)

	sent := a.Now() // {206 0}
	bClock.ms = 110
	got, err := b.Update(sent)
	if err != nil {
		t.Fatal(err)
	}
	if got != (Timestamp{Wall: 206, Logical: 1}) || !sent.Less(got) {
		t.Fatalf("got %v, want {206 1}, after %v", got, sent)
	}
}

func TestHLCNeverGoesBackwards(t *testing.T) {
	clk := &fakeClock{ms: 1000}
	h := NewHLC(clk.now, time.Second)
	first := h.Now()
	clk.ms = 400 // NTP stepped the clock back 600 ms
	if second := h.Now(); !first.Less(second) {
		t.Fatalf("%v is not after %v", second, first)
	}
}

func TestHLCRejectsFarFutureClock(t *testing.T) {
	h := NewHLC((&fakeClock{ms: 1000}).now, 500*time.Millisecond)
	if _, err := h.Update(Timestamp{Wall: 5000}); err == nil {
		t.Fatal("expected an error for a peer 4 s ahead")
	}
}

aClock.now (without parentheses) is a method value: the method bound to that particular aClock, passed around as a plain func() int64. All three tests pass with go test -race.

Who uses it

  • CockroachDB stamps every transaction with an HLC timestamp. It also enforces a maximum clock offset (500 ms by default for many years; it's a start-up flag, so check your version's docs): a node whose clock is too far from the others shuts itself down rather than risk wrong answers. The maxOffset check above is a tiny version of the same idea.
  • MongoDB uses a hybrid logical clock for its “cluster time” (since version 3.6), which powers causally consistent sessions: a client that wrote something is guaranteed to read it back, even from another replica (when it uses “majority” read and write concern, MongoDB's settings for how many replicas must confirm).
  • YugabyteDB uses HLC timestamps for its transactions, similar to CockroachDB.

See the gap, and the cheapest fix when the client is involved: let the client carry the timestamp from one request to the next.

Phone a friend
Client 1 writes on node X, then phones a friend, who writes on node Y. Y's clock is 50 ms slow. Real order: X's write first.
X (clock correct)Y (50 ms slow)write (100,0)📞 “done, your turn!”write (70,0)pt 70
Y's write gets (70,0), smaller than X's (100,0), although it happened 20 ms later. ✗ No message connected X and Y, only a phone call, so the HLC had nothing to follow. Y's slow physical clock decided. Switch the toggle.
Staff-level§10

TrueTime and Spanner: waiting out the uncertainty

In one sentenceYou can't make clocks perfect, but you can know how wrong they might be. Google's TrueTime returns an interval guaranteed to contain the true time, and Spanner simply waits until that interval has passed before saying “committed”.

An honest clock

Every clock reading is really “12:00:00.205, give or take a few milliseconds”. Normal APIs drop the “give or take”. TrueTime, the clock API inside Google's Spanner database, keeps it:

TT.now()  →  [earliest, latest]   // the true time is guaranteed to be in here

The half-width of the interval is called ε (epsilon), the clock uncertainty. To keep ε small, every Google data center has time servers with GPS receivers and atomic clocks (two kinds, because they fail for different reasons). Every machine polls them about every 30 seconds. Between polls, the machine assumes its own clock may drift at a pessimistic worst-case rate (200 µs per second in the 2012 paper), so ε grows until the next poll and then drops again. The paper reported ε of roughly 1–7 ms; Google has since said it's usually much lower.

The problem it solves

Spanner promises that if transaction T1 finishes before T2 starts (in real time, anywhere on Earth), then T1 gets a smaller commit timestamp. That matters because Spanner lets you read “the database as of timestamp t”: if T2's timestamp were smaller, a read could show T2's effects without T1's, even though T2 happened afterwards. Google calls this external consistency; elsewhere it's called strict serializability, the transaction version of linearizability (the CAP topic explains linearizability and puts both on its ladder of consistency models).

Notice that an HLC can't promise this (§09): if T1's client tells a friend by phone, and the friend starts T2 on another server, no message connected the two servers. Only real time did.

Commit wait

Spanner's rule, called commit wait, is short:

  1. When T1 is ready to commit, choose its timestamp s = TT.now().latest: a time that's surely not in the past yet.
  2. Wait until TT.now().earliest > s: until s is surely in the past in true time. From then on, every server's TT.now().latest (which is never below the true time) is already beyond s.
  3. Only then tell the client “committed”.

Anyone who hears about T1 after step 3, and then starts T2 anywhere, gets TT.now().latest as T2's timestamp, and that is already past s. So T2's timestamp is bigger. The wait is about 2ε: ε for latest to be reached, plus ε until even earliest passes it. Try it with worst-case clocks (server 1 is ε fast, server 2 is ε slow, both within their promise):

Commit wait
Transaction T1 commits on server 1. As soon as its client hears “committed”, a friend starts T2 on server 2. Does T2 get a bigger timestamp, as real-world order says it should?
0 ms5 ms10 ms15 ms20 ms25 ms30 ms35 mstrue time →T1 · srv 1T2 · srv 2earliestlatestcommits1 = 18“committed!” → friend starts T2earliestlatests2 = 11
s2 = 11 < s1 = 18. ✗ T2 started after T1 committed, yet its timestamp is smaller: anyone reading “as of time 11” would see T2's effects without T1's. Server 1's clock is ε fast and server 2's is ε slow; both are within their promised intervals, so nothing is “broken”. Turn on commit wait.

Without the wait, a 4 ms uncertainty is enough to give T2 a smaller timestamp. With it, every commit costs about 2ε of extra latency, and Spanner overlaps that wait with other work it has to do anyway (like replicating the commit, which takes a network round trip).

Without atomic clocks: CockroachDB's uncertainty intervals

CockroachDB runs on ordinary servers, so its uncertainty is the configured maximum clock offset: 500 ms by default for many years (it's a start-up flag, so check your version's docs). Waiting 2 × 500 ms on every commit would be far too slow. So it moves the problem to reads:

  • A transaction reads at its HLC timestamp t (§09).
  • If it finds a value written at a timestamp slightly above t, between t and t + max offset, it can't tell whether that write really happened before or after it started: the writer's clock might simply have been ahead. That window is the uncertainty interval.
  • Instead of guessing, the reader restarts at a higher timestamp that includes the write. Values written beyond t + max offset are definitely later and are ignored.

Drag the write along the timeline:

A read meets a nearby write
A transaction reads key k at timestamp 1000. Some other node wrote k with the timestamp you choose. Clocks may differ by up to the maximum offset.
60080010001200140016001800uncertainty interval (t … t + max offset)surely oldersurely laterread @ 1000writerestart @ 1250+
1250 is above 1000, but by less than the max offset (500 ms). The writer's clock might simply have been ahead, so this write may really have happened before the read started. The read can't tell, so it restarts at a timestamp just above 1250 and includes the write. Correct, at the cost of redoing some work. A smaller max offset means a smaller window and fewer restarts.

So the cost is paid only when reads and writes on the same data overlap in time, instead of on every commit. The trade-off: CockroachDB guarantees serializable transactions (the result is as if transactions ran one at a time) and no stale reads of a single key, but it doesn't make Spanner's promise for transactions on unrelated keys connected only by real time (like the phone call above). And correctness depends on the max offset really holding, which is why nodes check their offset against each other and shut down when it's exceeded.

Staff-level§11

Staff-level: time in real systems

In one sentenceIn production, use clocks for what humans read and for local durations. For order, use something that was designed for order: a sequence number, a log position, a parent ID, or a logical clock.

Logs and traces across services

Logs from one machine, sorted by timestamp, are in the right order (as long as the clock wasn't stepped). Logs from many machines, sorted by timestamp, are only roughly right: events less than the clock skew apart can appear swapped, exactly like §01.

  • Follow the request, not the clock. Give every incoming request an ID and pass it to every service it touches (a request ID or trace ID header). To reconstruct what happened, filter by ID, then order by cause: which call made which.
  • Distributed traces have the parent built in. Each span records its parent span. That's happened-before, recorded explicitly. With clock skew you'll still see oddities like a child span that starts before its parent; that's the skew between the two machines, not a bug in your code. Some trace viewers (Jaeger, for one) can adjust for it.
  • Within one process, add a sequence number to each log line if exact order matters; it's cheaper than trusting timestamps at microsecond resolution.

More on logs, metrics and traces in the Observability topic.

Order by sequence numbers, not by time

When one component is in charge of order, let it hand out numbers: a database sequence, an offset within one partition of Kafka, a Postgres LSN. These are exact and cheap, because a single writer assigns them. The cost is that single writer.

For IDs created on many machines at once, the Sharding topic covers UUIDv7 and Snowflake IDs. Both start with a timestamp, so they sort roughly by creation time, which is great for database indexes. But they inherit their machine's clock:

  • Two IDs created on different machines within the clock skew can sort in the wrong order. Never use them to decide “which write came first” across machines.
  • If the clock steps backwards, a generator could produce an ID smaller than its previous one, or even a duplicate. Twitter's original Snowflake generator refused to create IDs until the clock caught up again; check what your library does.

Leases: where a clock bug becomes a correctness bug

A lease is a lock with an expiry: “node 1 is the leader until 10 seconds from now.” Many leader-election and locking systems use them (the Consensus topic shows how leaders are chosen). A lease is only safe if the holder stops acting before anyone else thinks the lease has expired. That depends on:

  • Clock rate, not clock value: measure the lease with the monotonic clock (§03), as a duration from when it was granted. Never compare wall-clock times from two machines.
  • A safety margin for drift: if clocks can run 200 ppm apart, a 10 s lease can look about 2 ms longer to one side. The holder should give up a little early; the next holder should start a little late.
  • Pauses: a garbage-collection pause, a VM migration or a laptop lid can freeze a process for seconds. It wakes up still believing it holds the lease. No clock fixes that, which is why writes should also carry a fencing token that the storage checks (Replication §08 explains fencing). Step through it, then switch fencing off:
Lease, pause, fencing token
Two nodes take turns as leader using a 10-second lease. Storage can check a fencing token on every write.
node 1node 2storage0 s5 s10 s15 slease · token 33—
storage holds: —
Node 1 gets a 10-second lease from the lock service, with token 33. Tokens only ever go up.

UTC hygiene, in brief

  • Store and send UTC. In Postgres use timestamptz (it stores an absolute instant); in JSON use RFC 3339 strings with a Z or an explicit offset (2026-10-30T12:00:00Z). Go's time.Time and JavaScript's toISOString() both produce this.
  • Convert to local time only for display, using the user's time zone name (Europe/Berlin), not a fixed offset. Offsets change with daylight saving.
  • Schedule “every day at 09:00 local” in local time, with a time-zone database, not as “every 24 hours”: some days are 23 or 25 hours long.

Testing code that uses time

Code that calls time.Now() or Date.now() directly can't be tested quickly: to test a 10-minute expiry you'd wait 10 minutes. Instead, inject the clock, as the HLC in §09 does with its now field. That's dependency injection applied to time, and in tests you pass a fake clock that you move by hand.

fake_time_test.go
package lease

import (
	"testing"
	"testing/synctest" // stable since Go 1.25
	"time"
)

func TestFakeTime(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		start := time.Now()
		time.Sleep(time.Hour) // returns instantly: the fake clock jumps ahead
		if got := time.Since(start); got != time.Hour {
			t.Fatalf("got %v", got)
		}
	})
}

Both ran and passed (the Go one on Go 1.26). Go's testing/synctest package (stable since Go 1.25; Go 1.24 had an experimental synctest.Run behind GOEXPERIMENT=synctest) runs the function in a “bubble” where time.Now, time.Sleep and timers use a fake clock that jumps forward whenever every goroutine in the bubble is waiting. Node's mock.timers (still marked experimental in Node 22, so it prints a warning) fakes Date and, optionally, setTimeout. The simplest option of all works in both languages: pass a now function into your code and let the test supply one that returns whatever it wants.

Practitioner§12

Pitfalls gallery

#1

Measuring durations with the wall clock

A clock step makes the duration wrong, negative, or huge (§03). Timeouts never fire, or fire instantly; latency graphs lie.

✕ Don't
const start = Date.now();
await callUpstream();
const ms = Date.now() - start; // can be negative after a step
✓ Do
const start = performance.now();
await callUpstream();
const ms = performance.now() - start; // monotonic

In Go, time.Since(start) is safe only if start came from time.Now() in this process. A time read from JSON or a database has no monotonic reading.

#2

Ordering events from different machines by timestamp

Merging logs, picking the “latest” write (last write wins), or deciding which request came first by comparing timestamps from different servers is only right when the events are further apart than the clock skew. Close together, the order is a coin flip, and the loser of a last-write-wins comparison is silently discarded (Replication §09). Use a single sequencer, a logical clock or an HLC (§06–§09).

#3

Comparing time.Time with ==

== also compares the monotonic reading and the time zone, so the same instant can compare unequal (t == t.Round(0) is false). Use t.Equal(u), t.Before(u), t.After(u). Same for map keys: don't use time.Time as a key; use t.UnixNano() or a string.

#4

Assuming the wall clock never goes backwards

It does, whenever time sync steps a fast clock or a leap second is handled by repeating a second (§02). Code that breaks: ID generators that assume each new timestamp is bigger, caches computing “age” as now − createdAt, rate limiters measuring windows with Date.now(), “sort by created_at” used as the source of truth. Use monotonic time for durations and expect wall time to wobble.

#5

Using Lamport numbers to detect concurrency

L(a) < L(b) does not mean a happened before b; they may be concurrent (§06). If you need to know whether two writes conflicted, you need vector clocks or version vectors (§07).

#6

One vector-clock entry per client

Give every browser, phone or short-lived worker its own entry and vectors grow without limit: they're stored with every value and sent with every message. Give entries to the long-lived replicas that coordinate writes instead, and be careful with pruning: dropping entries can turn ordered versions into false conflicts or lost writes (§07).

#7

Trusting a lease or lock across a pause

A process that pauses (garbage collection, VM migration, a swapped-out page) can wake up after its lease expired and keep writing as if it were still the leader. Measure leases with the monotonic clock, leave a drift margin, and make storage reject stale writers with a fencing token (§11).

#8

Storing local time

2026-03-29 02:30 doesn't exist in Berlin (the clocks jump from 02:00 to 03:00), and 2026-10-25 02:30 happens twice. Store UTC instants (timestamptz, RFC 3339 with Z), and convert to the user's time zone only for display (§11).

Practitioner§13

Check yourself

Predict the answer before you click. Each explanation points to the section that covers it.

QUESTION 1 / 7
Lamport receive

Server B's Lamport clock is 2. A message arrives from A carrying Lamport time 5. What timestamp does B give the receive event?

QUESTION 2 / 7
Compare two vectors

Event X has vector [2,1,0] and event Y has [1,2,0] (order A, B, C). How are they related?

QUESTION 3 / 7
The clock jumps mid-request
start := time.Now()
doWork() // takes 1 s; meanwhile NTP steps the clock back 2 s
elapsed := time.Now().Sub(start)
fmt.Println(elapsed.Round(time.Second))
QUESTION 4 / 7
What do Lamport numbers prove?

Event a has Lamport time 3 and event b has Lamport time 7. What can you safely conclude?

QUESTION 5 / 7
Measuring in Node

You want to log how long each HTTP request took in a Node server. Which clock should you read at the start and the end?

QUESTION 6 / 7
Commit wait

TrueTime reports an uncertainty of ε = 5 ms (the true time is within ±5 ms of the reading). Roughly how long does a Spanner commit wait?

QUESTION 7 / 7
HLC receive

A node's HLC is (500, 2) and its physical clock reads 480. A message arrives carrying (500, 4). What is the new HLC?

Answered 0 / 7
Foundations§14

Cheat sheet

⏱️
Durations → monotonictime.Since(start) in Go, performance.now() in JS. Never subtract wall-clock times for timeouts.
🕰️
Time of day → wall clock, UTCFor humans and records. It drifts, gets stepped, and can go backwards.
📡
NTP is good, not perfectMilliseconds over the internet, much better in a data center. Error ≤ half the round trip. Monitor the offset.
➡️
Happened-beforea → b if a could have influenced b: same process, or a message, or a chain. Otherwise concurrent.
🔢
Lamport clock+1 per event; receive = max(mine, msg) + 1. a → b ⇒ L(a) < L(b), not the other way round.
🧮
Vector clockOne counter per process; receive = element-wise max, then +1 own. Detects concurrency exactly; grows with participants.
🧬
HLC(wall time, counter). Near real time, never backwards, respects causality. CockroachDB, MongoDB, YugabyteDB.
⏳
TrueTimeClock returns [earliest, latest]. Commit wait ≈ 2ε makes timestamp order match real-time order.
I need to…Use
Time a request or enforce a timeoutMonotonic clock (time.Since, performance.now(), context.WithTimeout)
Show or store when something happenedWall clock in UTC, RFC 3339 / timestamptz
Order events from one source exactlyA sequence number assigned by that source
Agree on one order of all events, cheaplyLamport timestamps + node ID tie-break
Detect concurrent (conflicting) writesVector clocks / version vectors
Timestamps near real time that respect causalityHybrid logical clocks
Real-time order across unconnected machinesTrueTime-style commit wait (uncertainty intervals only protect reads of the same data)
Hold leadership safelyLease measured on the monotonic clock + drift margin + fencing token
Test time-dependent codeInject a clock; fake it in tests (synctest, mock.timers)
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Distributed SystemsCAP & PACELC
Read →