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.nowfor a domain value, andClock.currentTimeMillisfor epoch ms ininfra/arithmetic. Both readClock. - Banned:
Date.now(),new Date(),Date.parse,DateTime.nowUnsafe,DateTime.isPastUnsafe,DateTime.isFutureUnsafe, andCron.next/Cron.prevwithoutnow. The lint ruleapp/no-host-timecovers the onesglobalDatedoes 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 givennow. - Elapsed time is monotonic (
Effect.timed,Effect.trackDuration), never two wall-clock reads subtracted. Waiting isEffect.sleep,Effect.timeoutor aSchedule.
❌ 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 itconst 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, aDate, a string or aDateTime.Zoned. - Slice tables use the native type:
timestamptz NOT NULLon Postgres (never plaintimestamp),INTEGER NOT NULLepoch ms on SQLite and D1. TheRowfield isSchema.DateTimeUtcFromDateorSchema.DateTimeUtcFromMillisper 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
DEFAULTfor an instant, and nonow(),CURRENT_TIMESTAMPorunixepoch()in application SQL. A cutoff is a bound parameter computed fromnow.
❌ 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
Send instants as ISO 8601 UTC strings
Section titled “Send instants as ISO 8601 UTC strings”Impact: MEDIUM the string says its unit and its zone
- An instant on the wire is
Schema.DateTimeUtcFromString, for example2026-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/makeZonedUnsafewithouttimeZone, andCron.parse/parseUnsafewithout 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 fromCurrentTimeZone. 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, thenid: 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 + 1WHERE 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.TestClockstarts 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 issetTimeto 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
Deferred
Section titled “Deferred”- Rejecting an instant string with no zone designator — trigger: the first API consumer that is not our web client.
- A
CalendarDateschema (YYYY-MM-DD,dateon Postgres,TEXTon SQLite/D1) — trigger: the first date-only field. - The user
time_zonecolumn andnext_run_atpattern — 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.