Two clocks and one stale number

A recently delivered counter update was already old, so T04P learned to preserve when an observation happened and when the system received it.

At 19:04, T04P received a counter update and displayed it as “just now.” The number had been observed at 18:59.

The five-minute difference was not a rendering delay. A disrupted link had buffered several messages and released them after reconnecting. From the server's point of view, the message was new. From the event floor's point of view, it was history.

I had stored one timestamp called updatedAt. Different parts of the program gave that name different meanings. The adapter treated it as the time a payload arrived. The browser treated it as the time the displayed fact became true. The label turned that ambiguity into a confident claim.

The bug was not that a clock was five minutes wrong. It was that one field was being asked to represent two events.

A timestamp needs a verb

updatedAt looked precise without saying what had been updated, by whom, or at which boundary. Renaming the field forced the missing questions into the model.

For each normalized observation, T04P began preserving:

type Observation<T> = {
  value: T
  observedAt: string | null
  receivedAt: string
  sourceSequence: number | null
  timeBasis: 'source-clock' | 'adapter-clock' | 'unknown'
}

observedAt meant the closest defensible time at which the source observed the condition. receivedAt meant when the adapter accepted the payload into T04P. Neither field was called simply time.

The timeBasis made uncertainty explicit. Some sources provided a timestamp from a reasonably managed clock. Others exposed a sequence but no time. A simple counter adapter could only stamp the response when it polled. Those cases should not produce equally exact prose.

The naming rule became simple: a timestamp needed a verb and a subject. “Source observed,” “adapter received,” “service committed,” and “browser rendered” were separate facts. If I could not complete that sentence, the field was probably hiding an assumption.

This rule added words to the code and removed arguments during debugging. A log line that said updatedAt=... invited interpretation. A line that put sourceObservedAt beside adapterReceivedAt exposed delay directly.

Receipt time measured the path

Receipt time was not useless just because it did not prove freshness. It measured a different part of the system.

When both clocks were trustworthy, their difference approximated the time an observation spent between source and adapter:

delivery delay = receivedAt - observedAt
observation age = now - observedAt
local silence   = now - receivedAt

The three values supported different diagnoses.

A large delivery delay with recent receipt meant the path had delivered old material. A small delay with long local silence meant nothing had arrived recently. A fresh receipt and fresh observation meant the feed was current. A negative delay meant at least one clock could not support the calculation.

I had originally used now - receivedAt as the freshness age. That made buffered messages look current and made a rapidly retried old response appear healthy. Receipt time described T04P's contact with the integration, not the age of the fact.

It remained useful for adapter monitoring. If the source timestamp stayed fixed but receipt time advanced, the adapter was active and the source was repeating an old observation. If neither advanced, the integration path itself might be stuck. The two clocks created a distinction that a single age could not.

The diagnostic view showed both, while the main dashboard translated them into a smaller explanation. Operators did not need to perform timestamp subtraction. The system did need enough information to choose honest language for them.

The source clock was evidence, not authority

Preserving source time exposed another temptation: trusting it because it came from the source.

One simulator scenario emitted an observation dated several minutes in the future. The first freshness function computed a negative age and classified the value as exceptionally current. That result was mathematically consistent and operationally absurd.

Real clocks drift. Devices reboot. Time zones are misconfigured. A source can serialize local time without an offset. Even a correctly synchronized clock can be attached to a process that stamped the record before waiting in a queue.

T04P therefore treated source time as a claim with bounds. The adapter checked whether the timestamp was parseable, included an unambiguous offset, and fell within a plausible range of its own clock. It did not silently correct a suspicious value. It recorded the anomaly and lowered the time basis.

function assessSourceTime(sourceTime: Date, receivedAt: Date) {
  const skew = sourceTime.getTime() - receivedAt.getTime()
 
  if (skew > MAX_FUTURE_SKEW) return 'future'
  if (skew < -MAX_DELIVERY_WINDOW) return 'old-or-delayed'
  return 'plausible'
}

The constants were feed policy, not universal truth. An equipment poll and a batched entry counter had different delivery expectations. The result also did not decide whether the value itself was valid. It described the timestamp relationship.

For a future timestamp, the interface avoided a false “in 3 minutes” age and said that observation time was unavailable. Diagnostics retained the original source value. That preserved evidence without letting broken chronology leak into the product claim.

Sequence sometimes carried more truth than time

Not every source had a clock worth using. One feed had a monotonically increasing sequence. Another exposed a snapshot version. Those identifiers could prove progress and order even when they could not place an observation on a wall clock.

I had been treating timestamps as the only sign of life because they made friendly labels. A sequence was less presentable and sometimes more dependable.

Suppose two messages arrived with source sequences 203 and 204. The second definitely followed the first within that source, even if their timestamps were equal or missing. If sequence 206 arrived next, T04P knew continuity was uncertain. No amount of fresh receipt time could erase the missing 205.

The normalized event kept both when available:

source order: 204
source observed: unknown
adapter received: 19:04:16

The UI could say “received moments ago; source observation time unavailable” while diagnostics showed that the sequence advanced. That was less satisfying than “current as of 19:04,” but it did not invent knowledge.

For a source that promised a heartbeat sequence every thirty seconds, advancing order also supported freshness within a wider bound. The adapter could say it had recent source evidence without claiming the physical observation occurred at an exact second.

Time and order answered related but different questions. Treating either as a complete history would have repeated the original compression mistake.

Parsing was part of the data contract

The project predated the comfortable assumption that every service would exchange a clean ISO timestamp. A few sample payloads included values such as:

2013-04-30 18:59:12
04/30/2013 6:59 PM
1367362752
2013-04-30T22:59:12Z

The first value had no time zone. The second mixed locale and a missing zone. The third could be seconds or milliseconds without a contract. Only the final value was unambiguous on its own.

I resisted making the adapter “smart” enough to guess. Guesses convert visible integration problems into silent chronology errors. Each source adapter received an explicit parser that matched the source contract. If the contract said venue-local time, the adapter applied the configured venue offset and recorded that basis. If the source could be changed, it emitted ISO 8601 with an offset.

Parsing failures did not default to new Date() or receipt time under the same field. The raw value remained available in diagnostics, observedAt became null, and the feed's presentation used a less precise statement.

This made tests slightly tedious and much more useful. I used fixtures around midnight, daylight-saving transitions, malformed offsets, and ambiguous date order. The goal was not to build a general date library. It was to make each accepted representation intentional.

The adapter boundary became the right place to eliminate ambiguity because every downstream component then received one normalized chronology model. The browser did not need to know which source once wrote 04/30/2013.

Freshness needed to change as time passed, which introduced a second category of clock inside the browser.

Wall-clock timestamps were necessary for explaining when an observation occurred and for comparing evidence across processes. Duration measurements, such as how long since a browser heartbeat, were safer with a monotonic clock where available. A system clock adjustment could otherwise make a timeout fire early, late, or not at all.

The browser code kept the distinction modest. It parsed persisted observation times against the current wall clock for display. It used elapsed-time measurements for short-lived transport deadlines. It recalculated visible ages from the underlying timestamp instead of repeatedly incrementing a counter.

Incrementing a displayed ageSeconds every second had seemed harmless. It drifted when the tab was throttled in the background. After a sleeping laptop woke, the counter resumed where it had stopped and made old data appear younger than it was.

Deriving age from Date.now() - observedAt repaired that display when the tab returned. It did not solve a wrong system clock, but it avoided confusing “number of timer callbacks executed” with elapsed wall time.

For transport round trips, the client cared about elapsed duration, not a calendar timestamp. Keeping those uses conceptually separate prevented a wall-clock correction from looking like extraordinary network latency.

Once the data model had two timestamps, I briefly exposed both beside every number. The result looked like an instrumentation panel and asked the reader to interpret a queueing system during an event.

The main view instead converted evidence into bounded language:

  • “Observed moments ago” when source time was recent and plausible.
  • “Last observed about 3 minutes ago” when evidence had aged.
  • “Received now; observation is about 5 minutes old” when a delayed batch arrived.
  • “Recently received; observation time unavailable” when only receipt was defensible.
  • “Time conflict” when the source clock fell outside its accepted bound.

The precise timestamps remained accessible in diagnostics. The primary text rounded age because a source that reported every thirty seconds did not justify an apparent precision of one second. Constantly changing seconds also added motion without changing the decision.

Threshold transitions were phrased around the source. Instead of a generic “stale,” the entry section could say “No recent entry observation.” It retained the last value only when the reader could see that the value belonged to an earlier moment.

This was not merely friendlier formatting. The words encoded the difference between delivery and observation. A value arriving now could still carry an old-time label, preventing the exact bug that began the investigation.

Sorting by one clock rewrote the story

The dashboard included a small event history. I initially sorted it by receipt time because that was complete and generated locally. After the link recovered, the delayed batch appeared after the recovery note, as if old entry changes had happened later.

Sorting exclusively by source observation time caused a different problem. Events with uncertain or drifting source clocks jumped around. Two independent sources did not share a guaranteed total order. The screen could not promise one perfect chronology.

The history adopted a deliberate presentation rule. Within one trustworthy source, observation order took precedence. Across sources, the default view used the time T04P learned of events and displayed delayed observations with their original time. A diagnostic toggle could group by source chronology.

An event stored both facts, so changing the view did not rewrite the record:

19:04:16 received counter observation from 18:59:12
19:04:17 received counter observation from 18:59:43
19:04:18 source snapshot confirms current count at 19:04:15

This read like a recovery, which was what had happened. Pretending all three events arrived on time would have erased the behavior we needed to understand.

The exercise taught me that ordering is a product decision as well as a data operation. A sorted list always implies a story about before and after. The chosen clock needs to match the story the reader is trying to reconstruct.

Testing time without waiting for it

The earliest freshness tests used short real timers. They were slow enough to be annoying and unstable enough to distrust. More importantly, they tested JavaScript scheduling alongside the actual policy.

I moved the policy into a pure classification function that accepted now:

type Freshness = 'current' | 'delayed' | 'stale' | 'unavailable'
 
function classifyObservation(
  now: number,
  observedAt: number | null,
  receivedAt: number,
  policy: FreshnessPolicy,
): Freshness {
  // Policy works from explicit evidence and supplied time.
}

Tests could place an observation one millisecond before and after every boundary. They covered a future source time, missing source time, a newly received old message, and a long browser sleep. The view timer only asked the classifier for a new result; it did not contain the rules.

I also wrote timeline fixtures with human-readable relative offsets:

source observed: T-5m
adapter received: T
now: T+2s
expected result: delayed, not current

These fixtures made review possible without performing calendar arithmetic. They expressed the semantic case that had failed: recent delivery of an old fact.

The tests did not prove the source clock was correct. They proved the product would preserve and communicate the evidence it received according to a stated policy.

Changing the schema created an awkward migration question. Older observations had only updatedAt, and the field had sometimes been assigned by the adapter and sometimes copied from a payload. I could not reliably reconstruct which meaning applied after the fact.

The tempting migration copied every old value into observedAt. That would have made the new schema complete by turning ambiguity into false source history. I left observedAt empty for records whose origin could not be proven and copied the legacy value into receivedAt only when the storage path guaranteed that interpretation. A migration note recorded the uncertainty.

Historical rows therefore became less precise after the new model arrived. That looked like a loss of data and was really a removal of unsupported meaning. The dashboard could still show their values in an archive with “observation time unavailable.” It could not use them to calculate source latency.

New records included a small schema version and time basis. The version was not exposed in the main interface, but it prevented later code from assuming every timestamp had the same provenance. Export routines included both clocks and their meaning in column names rather than shortening them back to time.

I also avoided rewriting an old event when a delayed correction arrived. The original observation remained as received; the correction became another attributed record pointing to it. That preserved what the dashboard had known at each moment. A mutable timestamp would have made the current row cleaner while making the incident impossible to reconstruct.

This was a small personal project, so the archive did not require elaborate event storage. The principle still mattered: improving a model should not retroactively claim evidence the old system never captured. Unknown is a legitimate migrated value.

What changed after the stale number

The fix was not to choose the better of two timestamps. T04P needed both because it needed to answer two questions.

When did the source observe this condition? When did our system receive the observation?

Once those questions were separate, delay became visible. Buffered recovery stopped making history look new. An active adapter stopped rejuvenating a repeated source record. The event history stopped pretending that receipt order was the only chronology. The interface could reduce precision when the source could not support it.

The change also made the limits clearer. T04P could not guarantee that a source clock matched reality. It could preserve the source's claim, assess it against known bounds, and avoid laundering receipt time into observation time.

I still use createdAt and updatedAt when the subject and operation are genuinely obvious. At system boundaries, I am suspicious of them. A timestamp without a verb often becomes a place where several histories are compressed into one convenient field.

At 19:04, the counter message really had just arrived. The count itself was five minutes old. The interface became trustworthy only when it learned to say both.