API reference › @evolu/common › local‑first/Timestamp

Hybrid logical clock timestamps for CRDT ordering.

Every change to a synced table becomes a CRDT message stamped with a Timestamp. The timestamp is the message's identity for sync, which reconciles sets of timestamps between devices and relays, and its order for conflicts, which last-writer-wins resolves per column by comparing timestamps. Each database keeps one clock. It advances when stamping local changes to synced tables and accepting incoming messages, so later writes sort after earlier local writes and accepted messages.

A device whose system clock is ahead produces future timestamps. They can override edits made later in real time on devices that have not yet accepted them. Accepting one advances the receiver's logical clock, so its later writes sort after the accepted message and carry future timestamps too, even before system time catches up. That propagation preserves ordering, and it does not compound: each device checks an incoming timestamp against its own system time. Counter rollover or a backwards system-clock adjustment can still put such a write in quarantine.

Clock drift

sendTimestamp and receiveTimestamp check the resulting clock against TimestampConfig.maxDrift, returning TimestampDriftError when it exceeds the limit. Receipt also checks the remote timestamp before arithmetic. The database uses the same isTimestampBeyondMaxDrift predicate to decide whether a message can be applied. Two situations matter here:

  • An incoming message has a timestamp too far ahead of the receiving device's system time. It can come from another device of the same owner or from a collaborator, and the check cannot tell whether the sender is ahead or the receiver behind.
  • The device's own system time is ahead and a write advances its logical clock. Drift is measured against the device's own system time, so the device accepts such writes as normal and stamps them ahead; other devices quarantine them. Only if system time then moves back far enough does the logical clock remain ahead and subsequent local changes go to quarantine. Ordinary incoming messages are still applied when their own timestamps are within the limit, even when the local clock is ahead. The clock is shared by all owners in the database.

System time is usually wrong by hours, rarely by years. A person sets the clock by hand, often misreading daylight saving time or the year, or picks a wrong time zone while the clock is set manually. A virtual machine resumes from a snapshot. Time synchronization corrects a clock that ran fast. A device with a dead clock battery boots into the past. Daylight saving time itself changes nothing, because timestamps use epoch milliseconds. Drift of years comes from a clock set to the wrong year, a bug, or, in collaboration, a vandalizing collaborator.

The database uses a five-minute limit. It tolerates a few minutes of difference between device clocks and quarantines messages hours or days ahead of system time. The limit is a trade-off: a smaller one quarantines more; a larger one admits more future skew and releases sooner. No limit orders independent offline edits by real time. Five minutes is a policy choice: the HLC paper, Section 4.2 leaves the tolerance to application semantics and suggests at most seconds for NTP-synchronized servers, which user devices are not. Actual Budget uses the same default, a precedent rather than proof.

Quarantine

When a message's own timestamp exceeds the limit, Evolu stores the message in quarantine without applying it to application tables. The database queue and sync continue; other messages and requests are processed normally. Completing a mutation means its changes are stored; some may be quarantined rather than visible in application queries.

Quarantine is state, not an error. Nothing is reported through the error channel; the quarantine table records each unapplied message with its reason, whether this database stamped it for a local mutation or received it, and the system time when it was quarantined. Applications watch that table through queries, and a subscribed query reflects a local mutation's quarantined rows before its completion callback runs. Quarantine works offline, independently of sync state, so the application can explain why the user's change is not visible.

A local change still receives the next logical timestamp, and that clock advance is persisted with the quarantined message. Further local changes also go to quarantine while their timestamps exceed the drift limit. New mutations apply normally once their timestamps fall within it; existing drift quarantine still waits for database worker startup. An incoming message is quarantined only when its own timestamp exceeds system time by more than the limit. receiveTimestamp rejects that timestamp before calculating the next clock, so the message keeps its original timestamp, quarantining it does not advance the local clock. For a message within the limit, the database applies the message and persists the next clock even if an already-ahead local clock or counter rollover produces a timestamp beyond the limit. An ahead local clock surfaces through local changes. A message whose timestamp is already in the owner's set is not written again: it was applied or quarantined before, and a duplicate cannot change that decision. Quarantined messages count as stored for sync and can be forwarded normally; each receiving device decides whether to apply or quarantine them. A quarantined timestamp far in the future also becomes the owner's last stored timestamp on every device and relay that stores it, so their later timestamps take the slower insert path of the timestamp skiplist instead of append until system time passes it. One device whose clock is set ahead is enough to cause this for the whole owner. The cost is a constant factor per stored message, the insert versus append workloads of the storage benchmark; ordering and sync are unaffected. Relays store and forward messages without checking clock drift: acceptance belongs to clients and must not depend on an honest relay or its system clock.

Quarantine does not itself make sync fail: completing sync does not mean every stored message has been applied to application tables.

Release

Drift quarantine is checked only when the database worker starts, as schema quarantine is. At that point, messages are released if their timestamps are no more than the drift limit ahead of system time, matching acceptance on a fresh receipt. Devices can therefore converge on the same visible state after their workers restart, regardless of delivery timing.

On the web, the tab holding the leader lock hosts the database worker. When that tab closes, reloads, or enters the browser's back-forward cache, a tab taking over leadership starts a replacement, and other open tabs refresh their subscribed queries. Reloading a non-leader tab does not restart the database worker.

Release checks use one captured system time. The logical clock advances over distinct released timestamps in timestamp order, as receipts do, so later local changes sort after them even before system time catches up. Released columns use last-writer-wins, so a future-stamped message overrides edits any device stamped before accepting or releasing it. Columns the schema does not define move to schema quarantine and are applied after a schema update. Duplicate delivery does not release: the timestamp is already in the owner's set. Release during a running database worker's session is not implemented. Correcting system time does not trigger release; eligible messages are released when the database worker next starts. Until then, a later message from one author can be visible while an earlier one is not, so an application can see a row that refers to one still in quarantine. Devices each within the limit can also be up to twice the limit apart, so a message one of them accepted can be quarantined by the other.

Release runs before the database worker reports its clock, so fresh requests start from the clock advanced by release. The stored clock never moves backwards. A replacement database worker's clock is adopted only if newer, so an empty memoryOnly replacement does not reset the session clock. Pending writes keep their captured inputs, so a replay reproduces the timestamps of the original attempt. Responses report their computed clock, which the SharedWorker adopts only if newer. Acquiring the replacement refreshes subscribed queries, because a startup release or a committed write whose response was lost would otherwise stay invisible.

Recovery

Recovery for messages further ahead than the drift limit is not implemented yet. These constraints shape it. Deleting quarantine rows is not a safe primitive: the timestamp stays in the owner's set and on relays, and a stored timestamp must be able to produce its message. Recovery therefore applies the rows early, re-authors them as a new mutation with a fresh timestamp, or marks them discarded. Re-authoring is only right for a local origin. All three leave the future-stamped message stored for sync with its original timestamp, so on other devices it still overrides edits stamped before they accept it. Applying early is acceptable while the rows are a short time ahead, as after a manual clock change; how short is an application decision, measured as the row's timestamp minus current time. Rows far ahead, from a clock set to the wrong year, a bug, or a vandalizing collaborator, require migrating the owner's visible state to a new owner with fresh timestamps; the old owner is abandoned. How relays treat an abandoned owner is not specified yet.

On the device whose clock ran ahead, fresh timestamps first need a clock reset. The database clock is shared by all owners and never moves backwards, so after that device's system time is corrected, every later timestamp is still at least as far ahead, for every owner, including a new one. Re-authored rows and a migration to a new owner would be quarantined too. Recovery there therefore starts by resetting the clock to system time with a fresh random node ID, which keeps timestamps stamped after the reset distinct from the future ones. The SharedWorker must adopt the reset clock although it is older than its session clock, and, like node ID rotation, the reset runs as its own request, never inside a replayed write. The reset is never automatic, because a clock that fell back, as after a dead clock battery, looks the same, and resetting then would stamp changes in the past. Nor is it a standalone action: after it, local edits to columns holding future-stamped values are stored but lose last-writer-wins without being quarantined, so the reset belongs only inside the migration to a new owner. Such a device can be recognized by local-origin drift quarantine whose timestamps exceed their quarantine time by about the skew.

Range ceiling

Counter rollover past the Millis ceiling, in August 10889, throws. The clock can get there only if the system clock came within the drift limit of the ceiling or the stored clock was tampered with, because receiveTimestamp checks remote drift before clock arithmetic. Such a clock is as broken as one past the ceiling, which Millis already rejects by throwing.

Duplicate node IDs

Detection and recovery are deliberately deferred to separate work. This includes the receive-first collision below, where a local mutation can complete without being stored.

The node ID is random per database and persisted in the clock. A database copied to another device, as when an operating system backup is restored to a new phone while the old one stays in use, leaves both independent copies stamping from the same node ID and clock. Tabs and Evolu instances sharing one database coordinate their writes through its shared clock.

For the same owner, two changes stamped in the same logical millisecond with the same counter get identical timestamps. If both copies write before receiving the other's change, each keeps its own version, while relays and third devices keep the first arrival. The copies can diverge with no error. This is likely while the copied clock is ahead of both devices' system time, because both stamp counters 1, 2, 3 in the same millisecond.

Receiving first can instead lose a local change. A copy quarantines an incoming timestamp beyond the drift limit without advancing its clock. Its next local mutation can then produce that same timestamp. The timestamp is already in the owner's set, so the local change is skipped: it is stored in neither application tables, history, nor quarantine, but onComplete still runs. The previously received change remains stored under that timestamp.

Detection: a received message whose timestamp is new to the owner's set but carries the local node ID cannot be ours, because every timestamp authored locally is already in the set before it can be sent, and a restored or recreated database mints a fresh node ID. applyMessages knows both facts when insertTimestamp reports a new timestamp. Messages whose timestamps are already stored are invisible to this rule; a new timestamp from the copy trips it.

An empty memoryOnly replacement is an exception: it can retain the SharedWorker's previous clock and node ID while losing the timestamp set. Detection must account for this before rotation, or our own earlier messages could be mistaken for another database's changes.

Handling: rotate the local node ID to a fresh random one, which changes only future timestamps, and report the copy through sync state or the error store so the application can warn that edits before detection may have diverged or been lost. Both copies detect each other and rotate, after which detection stops because messages with the old ID are no longer ours. Rotation cannot heal past collisions. Deleting and resyncing the database automatically is rejected: it drops unsynced changes and local-only tables, needs a relay, and does not recover the dropped half of a collision; restore from mnemonic is its manual form. The rotation must not happen inside the replayed write: a replay sees nothing new and would save the input clock with the old node ID again. The write's response flags the detection and the SharedWorker enqueues a separate rotation request, which is harmless to replay.

Functions

FunctionDescription
createInitialTimestamp-
createTimestamp-
isTimestampBeyondMaxDriftWhether timestamp milliseconds exceed TimestampConfig.maxDrift ahead of the supplied reference time. The exact limit and past timestamps are accepted.
nodeIdBytesToNodeIdConverts NodeIdBytes to NodeId.
nodeIdToNodeIdBytesConverts NodeId to NodeIdBytes.
receiveTimestampAdvances a Timestamp for a received one.
sendTimestampAdvances a Timestamp for a local event.
timestampBytesToTimestamp-
timestampToDateIsoConvert a Timestamp to an ISO 8601 DateIso string.
timestampToTimestampBytes-

Interfaces

InterfaceDescription
TimestampHybrid Logical Clock timestamp.
TimestampConfig-
TimestampConfigDep-
TimestampDriftErrorA timestamp exceeds TimestampConfig.maxDrift.

Type Aliases

Type AliasDescription
Counter-
NodeIdA NodeId identifies the database that stamped a timestamp. It is 64 random bits, generated when the database is created and persisted in its clock.
NodeIdBytesBinary representation of NodeId.
TimestampBytesSortable bytes representation of Timestamp.
TimestampErrorErrors from advancing a Timestamp.

Variables

VariableDescription
Counter-
defaultTimestampMaxDriftDefault value for TimestampConfig.maxDrift.
eqTimestampEquality function for comparing Timestamp.
maxCounter-
maxNodeId-
minCounter-
minNodeId-
NodeIdA NodeId identifies the database that stamped a timestamp. It is 64 random bits, generated when the database is created and persisted in its clock.
NodeIdBytesBinary representation of NodeId.
nodeIdBytesLengthLength of NodeIdBytes.
orderTimestampOrders Timestamp by milliseconds, counter, then node ID.
orderTimestampBytesAn Order for TimestampBytes.
TimestampHybrid Logical Clock timestamp.
TimestampBytesSortable bytes representation of Timestamp.
timestampBytesLength-