Skip to content

Hybrid logical clock

Every record version carries a version stamp in hlc. The stamp decides which of two versions of a mutable record wins. It comes from a hybrid logical clock (HLC): a clock that follows wall-clock time where possible and falls back to a counter where wall-clock time would not keep stamps in order.

001791792000123-000A-3F2A9C1B
└──────┬──────┘ └┬─┘ └──┬───┘
millis counter node
Part Width Content
millis 15 decimal digits, zero-padded Milliseconds since 1970-01-01T00:00:00Z
counter 4 hexadecimal digits, upper case, zero-padded Orders stamps within the same millisecond, 0 to FFFF
node 8 hexadecimal digits, upper case Identifies the device that made the stamp

The parts are joined with -, giving exactly 29 characters. Pattern: ^[0-9]{15}-[0-9A-F]{4}-[0-9A-F]{8}$.

Servers accept lower-case hexadecimal digits (/^\d{15}-[0-9A-F]{4}-[0-9A-F]{8}$/i) and store stamps in upper case. Writers MUST write upper case, because comparison is byte-wise (see below) and a sorts after B.

The stamp above is 1,791,792,000,123 milliseconds after the epoch, 2026-10-12T08:00:00.123Z, counter 10, node 3F2A9C1B.

The node id is 8 upper-case hexadecimal digits that identify the device. It keeps stamps of two devices that write in the same millisecond with the same counter apart.

The reference app creates a random UUID at first launch, stores it, and uses it as the device id at sign-in and as device_id on every push. The node id is the first 8 hexadecimal digits of that UUID in upper case: device id 3F2A9C1B-7E4D-4B2A-9C1E-5D8F0A2B4C6E has node 3F2A9C1B.

  • A node id MUST stay the same for the lifetime of an installation, and two installations SHOULD NOT share one. With 32 random bits a collision in an inventory of a few hundred devices is unlikely, and even then only two writes in the same millisecond with the same counter would compare equal.
  • The node 00000000 is used by the reference server for changes it writes itself (see Server-side writes). Clients SHOULD NOT use it.

A device keeps one clock with the last stamp it issued or observed, last (millis, counter). The node part is always the device's own node id. The clock is persisted indirectly: on start, the reference app sets last to the greatest stamp among all stored records.

Every local write (create, update or delete of a record) takes a new stamp:

wall = current time in milliseconds
if wall > last.millis:
last = (wall, 0)
else:
last = (last.millis, last.counter + 1)
stamp = format(last.millis, last.counter, own node)

The new stamp is greater than every stamp the device issued or observed before, even when the wall clock went back.

Every stamp the device receives (pulled changes, iCloud records) is observed before the change is merged, whether the change wins or not:

if remote.millis > last.millis
or (remote.millis == last.millis and remote.counter > last.counter):
last = (remote.millis, remote.counter)

After observing, the next local tick sorts after the remote stamp. This is what makes "edit after seeing someone else's edit" win, even if the editing device's wall clock is behind.

The counter has four hexadecimal digits. It grows only while the clock stays on the same millisecond, which needs 65,536 writes in a row without the wall clock passing last.millis. A counter above FFFF would produce a 5-digit counter and an invalid stamp that servers reject with invalid_change. Implementations MUST NOT let that happen: when the counter would exceed FFFF, move last.millis forward by one and reset the counter to 0, as the reference app does, or wait for the next millisecond.

Stamps are compared as strings, byte by byte (ordinal comparison, as strcmp does). Because every part has a fixed width and the digits sort in their numeric order in ASCII, string order equals the order of (millis, counter, node):

a b Result Why
001791792000123-0000-3F2A9C1B 001791792000124-0000-00000000 a < b earlier millisecond
001791792000123-0009-3F2A9C1B 001791792000123-000A-3F2A9C1B a < b counter 9 before counter 10
001791792000123-0001-9D04E6A2 001791792000123-0001-3F2A9C1B a > b same time and counter, node decides
001791792060000-0000-00000000 001791792000123-FFFF-FFFFFFFF a > b time wins over counter and node
001791792000123-000a-3F2A9C1B 001791792000123-000B-3F2A9C1B a > b lower case sorts after upper case: write upper case

Implementations MUST NOT compare with locale-aware collation. Databases that compare strings with a non-binary collation (SQL Server, MySQL with case-insensitive collations) give wrong results; the reference server therefore compares stamps in PHP with strcmp after reading the stored stamp.

Two different versions with the same stamp cannot be ordered; the merge rules treat an equal stamp as "not newer", so the version that arrived first stays.

The HLC keeps causality: a device that has seen a version always writes a greater stamp. It does not fix wrong clocks between devices that have not seen each other's writes. Between two concurrent edits, the one with the later wall clock wins.

  • A device whose clock is behind loses concurrent edits against devices with correct clocks, but every edit it makes after it has synced wins as expected.
  • A device whose clock is far ahead (for example set to the next year) produces stamps that win against everything for that long. Every device that observes such a stamp moves its own clock forward to it and keeps counting from there, so the effect spreads instead of staying with one device. Neither the reference app nor the reference server limits how far a stamp may be in the future.

Servers MAY reject stamps whose time is far ahead of their own clock with invalid_change, to stop a broken device clock from spreading. The reference server does not do this. Clients SHOULD warn the user when their clock is far behind or ahead of the Date header of server responses.

A server that changes a record itself (the reference server does this when an administrator sets the loan conditions with lve:loan-terms) stamps the change like a device with node 00000000, and with a time that guarantees the change wins:

now = server time in milliseconds
if stored.millis >= now:
stamp = (stored.millis, stored.counter + 1, "00000000")
else:
stamp = (now, 0, "00000000")

Such a change enters the change log like a pushed change and reaches every device with the next pull.

Two devices edit the same article. Device A (node 3F2A9C1B) has a correct clock; device B (node 9D04E6A2) is 30 seconds behind.

Step Event Stamp
1 A creates the article at 08:00:00.123 001791792000123-0000-3F2A9C1B
2 B pulls it; B observes the stamp, its last becomes (1791792000123, 0)
3 B renames the article. B's wall clock reads 07:59:40.000 (1791791980000), behind last, so the counter increases 001791792000123-0001-9D04E6A2
4 B pushes; the server applies it because it is greater than step 1
5 A, offline since step 1, changes the unit at 08:00:30.000 001791792030000-0000-3F2A9C1B
6 A pushes; the server applies it because it is greater than step 3

Result: A's version from step 5 wins on every device, including B after its next pull. B's rename is lost, because records are replaced as a whole. Had A pulled B's version before editing, A's form would have shown the new name and kept it.

The machine-readable vectors, including a step-by-step clock run, are in test-vectors/hlc.json.