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, and a timestamp at the range ceiling is quarantined
rather than failing its batch with a range error. 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 or reloads, 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 outside this change's scope. Correcting system time does not trigger release; eligible messages are released when the database worker next starts.
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.
Range error
TimestampTimeOutOfRangeError means counter rollover would move the
next logical timestamp past the Millis ceiling. receiveTimestamp
checks remote drift before arithmetic, so a far-future message is quarantined
before it can cause a range error. With ordinary system time and stored
timestamps, the database does not approach the range ceiling.
It is an EvoluError. The application tells the user to fix the clock and restart the app. On receipt, the batch is not written and the connection continues; the timestamps are missing from the owner's set, so range reconciliation resends the batch when a connection opens, as after a restart or a reconnect. A manual sync trigger, planned as a public API, will allow that without reconnecting. A local mutation that hits it is rolled back and reported, but the database worker posts no queued response for it, so the shared worker never completes that request: later requests for the database wait, the mutation's completion callbacks stay registered, and replacing the leader replays the request with the captured system time and fails the same way; a restart discards the queue and the mutation. This is left as is because the condition is unreachable with a real system clock. Completing the queue requires a rejection response from the database worker that the shared worker turns into queue completion and releases the mutation's completion callbacks without invoking them.
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.
Discriminated unions
| Interface | Description |
|---|---|
| TimestampTimeOutOfRangeError | A TypeScript interface with a literal type property. |
Functions
| Function | Description |
|---|---|
| createInitialTimestamp | - |
| createTimestamp | - |
| isTimestampBeyondMaxDrift | Whether timestamp milliseconds exceed TimestampConfig.maxDrift ahead of the supplied reference time. The exact limit and past timestamps are accepted. |
| nodeIdBytesToNodeId | Converts NodeIdBytes to NodeId. |
| nodeIdToNodeIdBytes | Converts NodeId to NodeIdBytes. |
| receiveTimestamp | Advances a Timestamp for a received one. |
| sendTimestamp | Advances a Timestamp for a local event. |
| timestampBytesToTimestamp | - |
| timestampToDateIso | Convert a Timestamp to an ISO 8601 DateIso string. |
| timestampToTimestampBytes | - |
Interfaces
| Interface | Description |
|---|---|
| Timestamp | Hybrid Logical Clock timestamp. |
| TimestampConfig | - |
| TimestampConfigDep | - |
| TimestampDriftError | A timestamp exceeds TimestampConfig.maxDrift. |
Type Aliases
| Type Alias | Description |
|---|---|
| Counter | - |
| NodeId | A NodeId uniquely identifies an owner's device. Generated once per device using cryptographic randomness. |
| NodeIdBytes | Binary representation of NodeId. |
| TimestampBytes | Sortable bytes representation of Timestamp. |
| TimestampError | Errors from advancing a Timestamp. |
Variables
| Variable | Description |
|---|---|
| Counter | - |
| defaultTimestampMaxDrift | Default value for TimestampConfig.maxDrift. |
| eqTimestamp | Equality function for comparing Timestamp. |
| maxCounter | - |
| maxNodeId | - |
| minCounter | - |
| minNodeId | - |
| NodeId | A NodeId uniquely identifies an owner's device. Generated once per device using cryptographic randomness. |
| NodeIdBytes | Binary representation of NodeId. |
| nodeIdBytesLength | Length of NodeIdBytes. |
| orderTimestamp | Orders Timestamp by milliseconds, counter, then node ID. |
| orderTimestampBytes | An Order for TimestampBytes. |
| Timestamp | Hybrid Logical Clock timestamp. |
| TimestampBytes | Sortable bytes representation of Timestamp. |
| timestampBytesLength | - |