Skip to content

Time, clocks & ordering

Time bugs hide in expiry, clock skew, DST and the host’s zone, all of which depend on machine state nobody sees. This page answers where “now” comes from, how an instant is typed, stored and sent, and what decides order when two machines disagree about the time.

Read “now” from Clock once, and pass it down

Section titled “Read “now” from Clock once, and pass it down”

Impact: HIGH TestClock only works if nothing bypasses Clock

  • Backend code reads the wall clock through two calls only: DateTime.now for a domain value, and Clock.currentTimeMillis for epoch ms in infra/ arithmetic. Both read Clock.
  • Banned: Date.now(), new Date(), Date.parse, DateTime.nowUnsafe, DateTime.isPastUnsafe, DateTime.isFutureUnsafe, and Cron.next/Cron.prev without now. The lint rule app/no-host-time covers the ones globalDate does not.
  • A use-case reads “now” once at its start; every row it writes carries that instant. Domain functions, time-dependent statements and libraries (a JWT verifier’s currentDate) are given now.
  • Elapsed time is monotonic (Effect.timed, Effect.trackDuration), never two wall-clock reads subtracted. Waiting is Effect.sleep, Effect.timeout or a Schedule.

❌ Incorrect — the clock read deep inside, twice:

export const isExpired = (rule: Rule): boolean =>
Option.exists(rule.expiresAt, (at) => DateTime.isPastUnsafe(at))
const rule = Rule.make({ id, evidence, createdAt: DateTime.nowUnsafe(), updatedAt: DateTime.nowUnsafe() })

✅ Correct — one read at the use-case, passed as a value:

const now = yield* DateTime.now // Clock-backed: TestClock drives it
const rule = Rule.make({ id: yield* newId(RuleId), evidence, createdAt: now, updatedAt: now })
yield* rules.insert(orgId, rule)
export const isExpired = (rule: Rule, now: DateTime.Utc): boolean =>
Option.exists(rule.expiresAt, (at) => DateTime.isLessThanOrEqualTo(at, now))

Source: notes/09-production-concerns/time-and-clocks.md · Decision 1, amended

Store instants as the app wrote them, never with a database clock

Section titled “Store instants as the app wrote them, never with a database clock”

Impact: HIGH one clock per replica, and a type that carries its unit

  • Every instant in a domain entity is a DateTime.Utc: never a number, a Date, a string or a DateTime.Zoned.
  • Slice tables use the native type: timestamptz NOT NULL on Postgres (never plain timestamp), INTEGER NOT NULL epoch ms on SQLite and D1. The Row field is Schema.DateTimeUtcFromDate or Schema.DateTimeUtcFromMillis per dialect.
  • infra/ tables (jobs_leases, outbox_events, receipts) store epoch ms (BIGINT/INTEGER) on every dialect, so their SQL is written once and binds ${now} as a number.
  • No column DEFAULT for an instant, and no now(), CURRENT_TIMESTAMP or unixepoch() in application SQL. A cutoff is a bound parameter computed from now.

❌ Incorrect — the database is a second clock:

created_at timestamp DEFAULT now()
-- …
UPDATE api_tokens SET last_used_at = now() WHERE last_used_at < now() - interval '5 minutes'

✅ Correct — the app’s now, bound in the column’s encoded form:

sql`UPDATE api_tokens SET last_used_at = ${DateTime.toDateUtc(now)}
WHERE last_used_at < ${DateTime.toDateUtc(DateTime.subtract(now, { minutes: 5 }))}`

Source: notes/09-production-concerns/time-and-clocks.md · Decisions 1–2

Impact: MEDIUM the string says its unit and its zone

  • An instant on the wire is Schema.DateTimeUtcFromString, for example 2026-10-11T08:00:00.000Z.
  • A zone travels as its own field, an IANA name. It is never folded into the instant.
  • A calendar date with no time of day is YYYY-MM-DD, not an instant. A duration is an integer whose name carries its unit (retryAfterSeconds, timeoutMs).

❌ Incorrect — a bare number and an ambiguous duration:

const RuleResponse = Schema.Struct({ createdAt: Schema.Number, retryAfter: Schema.Number })

✅ Correct — the type and the name say what the value is:

const RuleResponse = Schema.Struct({
createdAt: Schema.DateTimeUtcFromString,
retryAfterSeconds: Schema.Int,
})

Source: notes/09-production-concerns/time-and-clocks.md · Decision 3

Keep the server zone-free; the user’s zone is data

Section titled “Keep the server zone-free; the user’s zone is data”

Impact: HIGH host-zone bugs pass CI when CI is on UTC

  • The server never depends on the host’s zone. Also banned: DateTime.zoneMakeLocal, DateTime.withCurrentZoneLocal, DateTime.layerCurrentZoneLocal, makeZoned/makeZonedUnsafe without timeZone, and Cron.parse/parseUnsafe without a zone.
  • The container image sets TZ=UTC (the Worker already is). The client renders in the browser’s zone; the server converts only to answer a calendar question.
  • A user’s zone is an IANA name, decoded with Schema.TimeZoneNamedFromString, never an offset. A calendar computation takes it as an explicit argument, not from CurrentTimeZone. Calendar math runs in the app, not SQL (D1 has no zone database).
  • A user-set recurring schedule stores the rule, the zone and a next_run_at, recomputed after every run and edit.

❌ Incorrect — the host’s zone decides which day it is:

const day = DateTime.formatIsoDate(DateTime.makeZonedUnsafe(instant))
const next = Cron.next(Cron.parseUnsafe(schedule.cron))

✅ Correct — the zone is an argument:

const day = DateTime.formatIsoDate(DateTime.makeZonedUnsafe(instant, { timeZone: user.timeZone }))
const next = Cron.next(Cron.parseUnsafe(schedule.cron, schedule.timeZone), now)

Source: notes/09-production-concerns/time-and-clocks.md · Decision 4

Take order from the database, never from two clocks

Section titled “Take order from the database, never from two clocks”

Impact: HIGH replicas’ clocks differ and two writes can share a millisecond

  • Never decide order, causality or “which write wins” by comparing timestamps written on different machines. Two writers on one row are resolved by UPDATE … WHERE version = ${expected}.
  • Listing order is created_at, then id: approximate across replicas, fine for display. Exact order within a stream comes from a database-assigned sequence.
  • Leases compare against another replica’s deadline with a 1-minute margin, plus a far-future clause so a forward clock jump cannot wedge a job. Never mix the database’s clock and the app’s in one predicate.
  • An external party’s timestamp (JWT exp, Retry-After) gets a fixed 60-second tolerance. A watermark over an external system uses that system’s timestamps, re-read with an overlap.

❌ Incorrect — last write wins by wall clock:

UPDATE labeling_rules SET evidence = ${evidence}, updated_at = ${now}
WHERE org_id = ${orgId} AND id = ${ruleId} AND updated_at < ${now}

✅ Correct — the row version decides; the lease has a far-future escape:

UPDATE labeling_rules SET evidence = ${evidence}, version = version + 1
WHERE org_id = ${orgId} AND id = ${ruleId} AND version = ${expected}
-- jobs claim
… OR jobs_leases.lease_until > ${now + 2 * leaseMs}

Source: notes/09-production-concerns/time-and-clocks.md · Decision 5

Test time under TestClock, in a non-UTC zone

Section titled “Test time under TestClock, in a non-UTC zone”

Impact: MEDIUM makes skew, DST and zone bugs fail in CI

  • A test that depends on “now” calls TestClock.setTime(Date.UTC(…)) before anything reads it. TestClock starts at epoch 0.
  • Every lease gets a skew test: a claimer just inside the margin does not take a live lease; one past the far-future bound does.
  • Every calendar computation gets a DST test (a week with a transition in America/New_York, plus a zone east of UTC). A backwards clock jump is setTime to an earlier instant.
  • The suite runs under TZ=America/New_York, set in the test script, not in a test file. See testing.

❌ Incorrect — CI on UTC, and “now” left at epoch 0:

{ "scripts": { "test": "vitest run" } }

✅ Correct — a zone with DST and a negative offset:

{ "scripts": { "test": "TZ=America/New_York vitest run" } }

Source: notes/09-production-concerns/time-and-clocks.md · Decision 6

  • Rejecting an instant string with no zone designator — trigger: the first API consumer that is not our web client.
  • A CalendarDate schema (YYYY-MM-DD, date on Postgres, TEXT on SQLite/D1) — trigger: the first date-only field.
  • The user time_zone column and next_run_at pattern — trigger: the first feature that answers in the user’s zone.
  • A database-assigned sequence column on a stream — trigger: the first consumer that must see a stream’s events in commit order.
  • A clock-skew probe (each replica measures its offset from the database at boot, as a gauge) — trigger: the first incident traced to replicas disagreeing about time.